2022-08-26 17:31:25 +00:00
|
|
|
import { ClockIcon } from '@heroicons/react/outline'
|
|
|
|
import {
|
|
|
|
ChevronLeftIcon,
|
|
|
|
ChevronRightIcon,
|
|
|
|
UsersIcon,
|
|
|
|
} from '@heroicons/react/solid'
|
|
|
|
import clsx from 'clsx'
|
2022-08-27 00:13:49 +00:00
|
|
|
import {
|
|
|
|
BinaryContract,
|
|
|
|
Contract,
|
|
|
|
PseudoNumericContract,
|
|
|
|
} from 'common/contract'
|
2022-08-26 17:31:25 +00:00
|
|
|
import { Group } from 'common/group'
|
|
|
|
import dayjs, { Dayjs } from 'dayjs'
|
|
|
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
|
|
|
import timezone from 'dayjs/plugin/timezone'
|
|
|
|
import utc from 'dayjs/plugin/utc'
|
2022-08-27 00:13:49 +00:00
|
|
|
import { keyBy, mapValues, sortBy, throttle } from 'lodash'
|
2022-08-26 23:23:44 +00:00
|
|
|
import Image, { ImageProps, StaticImageData } from 'next/image'
|
2022-08-26 17:31:25 +00:00
|
|
|
import Link from 'next/link'
|
|
|
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
|
|
|
import { ContractCard } from 'web/components/contract/contract-card'
|
|
|
|
import { DateTimeTooltip } from 'web/components/datetime-tooltip'
|
|
|
|
import { Col } from 'web/components/layout/col'
|
|
|
|
import { Row } from 'web/components/layout/row'
|
|
|
|
import { Page } from 'web/components/page'
|
|
|
|
import { SEO } from 'web/components/SEO'
|
|
|
|
import { listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
|
|
|
import { getGroup, groupPath } from 'web/lib/firebase/groups'
|
2022-08-26 23:23:44 +00:00
|
|
|
import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png'
|
|
|
|
import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png'
|
|
|
|
import mpox_pic from './_cspi/Monkeypox_Cases.png'
|
|
|
|
import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png'
|
|
|
|
import { SiteLink } from 'web/components/site-link'
|
2022-08-27 00:13:49 +00:00
|
|
|
import { getProbability } from 'common/calculate'
|
2022-08-26 17:31:25 +00:00
|
|
|
|
|
|
|
dayjs.extend(utc)
|
|
|
|
dayjs.extend(timezone)
|
|
|
|
dayjs.extend(customParseFormat)
|
|
|
|
const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles')
|
|
|
|
|
|
|
|
type Tourney = {
|
|
|
|
title: string
|
|
|
|
url?: string
|
|
|
|
blurb: string // actual description in the click-through
|
|
|
|
award?: string
|
|
|
|
endTime?: Dayjs
|
|
|
|
groupId: string
|
|
|
|
}
|
|
|
|
|
|
|
|
const Salem = {
|
|
|
|
title: 'CSPI/Salem Forecasting Tournament',
|
|
|
|
blurb: 'Top 5 traders qualify for a UT Austin research fellowship.',
|
|
|
|
url: 'https://salemcenter.manifold.markets/',
|
|
|
|
award: '$25,000',
|
|
|
|
endTime: toDate('Jul 31, 2023'),
|
2022-08-26 23:23:44 +00:00
|
|
|
markets: [],
|
|
|
|
images: [
|
|
|
|
{
|
|
|
|
marketUrl:
|
|
|
|
'https://salemcenter.manifold.markets/SalemCenter/will-elon-musk-buy-twitter',
|
|
|
|
image: elon_pic,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
marketUrl:
|
|
|
|
'https://salemcenter.manifold.markets/SalemCenter/chinese-military-action-against-tai',
|
|
|
|
image: china_pic,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
marketUrl:
|
|
|
|
'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022',
|
|
|
|
image: mpox_pic,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
marketUrl:
|
|
|
|
'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022',
|
|
|
|
image: race_pic,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}
|
2022-08-26 17:31:25 +00:00
|
|
|
|
|
|
|
const tourneys: Tourney[] = [
|
|
|
|
{
|
|
|
|
title: 'Cause Exploration Prizes',
|
|
|
|
blurb:
|
|
|
|
'Which new charity ideas will Open Philanthropy find most promising?',
|
|
|
|
award: 'M$100k',
|
|
|
|
endTime: toDate('Sep 9, 2022'),
|
|
|
|
groupId: 'cMcpBQ2p452jEcJD2SFw',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
title: 'Fantasy Football Stock Exchange',
|
|
|
|
blurb: 'How many points will each NFL player score this season?',
|
|
|
|
award: '$2,500',
|
|
|
|
endTime: toDate('Jan 6, 2023'),
|
|
|
|
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
|
|
|
},
|
2022-08-29 04:33:11 +00:00
|
|
|
{
|
|
|
|
title: 'SF 2022 Ballot',
|
|
|
|
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
|
|
|
award: '',
|
|
|
|
endTime: toDate('Nov 8, 2022'),
|
|
|
|
groupId: 'VkWZyS5yxs8XWUJrX9eq',
|
|
|
|
},
|
2022-08-26 17:31:25 +00:00
|
|
|
// {
|
|
|
|
// title: 'Clearer Thinking Regrant Project',
|
|
|
|
// blurb: 'Something amazing',
|
|
|
|
// award: '$10,000',
|
|
|
|
// endTime: toDate('Sep 22, 2022'),
|
|
|
|
// groupId: '2VsVVFGhKtIdJnQRAXVb',
|
|
|
|
// },
|
|
|
|
]
|
|
|
|
|
|
|
|
export async function getStaticProps() {
|
|
|
|
const groupIds = tourneys
|
|
|
|
.map((data) => data.groupId)
|
|
|
|
.filter((id) => id != undefined) as string[]
|
|
|
|
const groups = (await Promise.all(groupIds.map(getGroup)))
|
|
|
|
// Then remove undefined groups
|
|
|
|
.filter(Boolean) as Group[]
|
|
|
|
|
|
|
|
const contracts = await Promise.all(
|
|
|
|
groups.map((g) => listContractsByGroupSlug(g?.slug ?? ''))
|
|
|
|
)
|
|
|
|
|
|
|
|
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
|
|
|
|
|
|
|
|
const groupMap = keyBy(groups, 'id')
|
|
|
|
const numPeople = mapValues(groupMap, (g) => g?.memberIds.length)
|
|
|
|
const slugs = mapValues(groupMap, 'slug')
|
|
|
|
|
|
|
|
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
|
|
|
|
}
|
|
|
|
|
|
|
|
export default function TournamentPage(props: {
|
|
|
|
markets: { [groupId: string]: Contract[] }
|
|
|
|
numPeople: { [groupId: string]: number }
|
|
|
|
slugs: { [groupId: string]: string }
|
|
|
|
}) {
|
|
|
|
const { markets = {}, numPeople = {}, slugs = {} } = props
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Page>
|
|
|
|
<SEO
|
|
|
|
title="Tournaments"
|
|
|
|
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
|
|
|
/>
|
|
|
|
<Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]">
|
|
|
|
{tourneys.map(({ groupId, ...data }) => (
|
|
|
|
<Section
|
|
|
|
key={groupId}
|
|
|
|
{...data}
|
|
|
|
url={groupPath(slugs[groupId])}
|
|
|
|
ppl={numPeople[groupId] ?? 0}
|
|
|
|
markets={markets[groupId] ?? []}
|
|
|
|
/>
|
|
|
|
))}
|
2022-08-26 23:23:44 +00:00
|
|
|
<Section {...Salem} />
|
2022-08-26 17:31:25 +00:00
|
|
|
</Col>
|
|
|
|
</Page>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
function Section(props: {
|
|
|
|
title: string
|
|
|
|
url: string
|
|
|
|
blurb: string
|
|
|
|
award?: string
|
|
|
|
ppl?: number
|
|
|
|
endTime?: Dayjs
|
|
|
|
markets: Contract[]
|
2022-08-26 23:23:44 +00:00
|
|
|
images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi
|
2022-08-26 17:31:25 +00:00
|
|
|
}) {
|
2022-08-27 00:13:49 +00:00
|
|
|
const { title, url, blurb, award, ppl, endTime, images } = props
|
|
|
|
// Sort markets by probability, highest % first
|
|
|
|
const markets = sortBy(props.markets, (c) =>
|
|
|
|
getProbability(c as BinaryContract | PseudoNumericContract)
|
|
|
|
)
|
|
|
|
.reverse()
|
|
|
|
.filter((c) => !c.isResolved)
|
2022-08-26 17:31:25 +00:00
|
|
|
|
|
|
|
return (
|
|
|
|
<div>
|
|
|
|
<Link href={url}>
|
|
|
|
<a className="group mb-3 flex flex-wrap justify-between">
|
|
|
|
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
|
|
|
|
{title}
|
|
|
|
</h2>
|
|
|
|
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
|
|
|
|
{!!award && <span className="flex items-center">🏆 {award}</span>}
|
|
|
|
{!!ppl && (
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
<UsersIcon className="h-4" />
|
|
|
|
{ppl}
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
{endTime && (
|
2022-08-27 20:46:35 +00:00
|
|
|
<DateTimeTooltip time={endTime.valueOf()} text="Ends">
|
2022-08-26 17:31:25 +00:00
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
<ClockIcon className="h-4" />
|
|
|
|
{endTime.format('MMM D')}
|
|
|
|
</span>
|
|
|
|
</DateTimeTooltip>
|
|
|
|
)}
|
|
|
|
</Row>
|
|
|
|
</a>
|
|
|
|
</Link>
|
|
|
|
<span>{blurb}</span>
|
|
|
|
<Carousel className="-mx-4 mt-2 sm:-mx-10">
|
|
|
|
<div className="shrink-0 sm:w-6" />
|
|
|
|
{markets.length ? (
|
|
|
|
markets.map((m) => (
|
|
|
|
<ContractCard
|
|
|
|
contract={m}
|
|
|
|
hideGroupLink
|
2022-08-29 05:29:59 +00:00
|
|
|
className="mb-2 max-h-[200px] w-96 shrink-0"
|
2022-08-26 17:31:25 +00:00
|
|
|
questionClass="line-clamp-3"
|
2022-08-29 05:15:21 +00:00
|
|
|
trackingPostfix=" tournament"
|
2022-08-26 17:31:25 +00:00
|
|
|
/>
|
|
|
|
))
|
|
|
|
) : (
|
2022-08-26 23:23:44 +00:00
|
|
|
<>
|
|
|
|
{images?.map(({ marketUrl, image }) => (
|
|
|
|
<a href={marketUrl} className="hover:brightness-95">
|
|
|
|
<NaturalImage src={image} />
|
|
|
|
</a>
|
|
|
|
))}
|
|
|
|
<SiteLink
|
|
|
|
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
|
|
|
|
href={url}
|
|
|
|
>
|
|
|
|
See more
|
|
|
|
</SiteLink>
|
|
|
|
</>
|
2022-08-26 17:31:25 +00:00
|
|
|
)}
|
|
|
|
</Carousel>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-08-26 23:23:44 +00:00
|
|
|
// stole: https://stackoverflow.com/questions/66845889/next-js-image-how-to-maintain-aspect-ratio-and-add-letterboxes-when-needed
|
|
|
|
const NaturalImage = (props: ImageProps) => {
|
|
|
|
const [ratio, setRatio] = useState(4 / 1)
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Image
|
|
|
|
{...props}
|
|
|
|
width={148 * ratio}
|
|
|
|
height={148}
|
|
|
|
layout="fixed"
|
|
|
|
onLoadingComplete={({ naturalWidth, naturalHeight }) =>
|
|
|
|
setRatio(naturalWidth / naturalHeight)
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-08-26 17:31:25 +00:00
|
|
|
function Carousel(props: { children: ReactNode; className?: string }) {
|
|
|
|
const { children, className } = props
|
|
|
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
|
|
|
|
const th = (f: () => any) => throttle(f, 500, { trailing: false })
|
|
|
|
const scrollLeft = th(() =>
|
|
|
|
ref.current?.scrollBy({ left: -ref.current.clientWidth })
|
|
|
|
)
|
|
|
|
const scrollRight = th(() =>
|
|
|
|
ref.current?.scrollBy({ left: ref.current.clientWidth })
|
|
|
|
)
|
|
|
|
|
|
|
|
const [atFront, setAtFront] = useState(true)
|
|
|
|
const [atBack, setAtBack] = useState(false)
|
|
|
|
const onScroll = throttle(() => {
|
|
|
|
if (ref.current) {
|
|
|
|
const { scrollLeft, clientWidth, scrollWidth } = ref.current
|
|
|
|
setAtFront(scrollLeft < 80)
|
|
|
|
setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80)
|
|
|
|
}
|
|
|
|
}, 500)
|
|
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(onScroll, [])
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className={clsx('relative', className)}>
|
|
|
|
<Row
|
|
|
|
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
|
|
|
|
ref={ref}
|
|
|
|
onScroll={onScroll}
|
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</Row>
|
|
|
|
{!atFront && (
|
|
|
|
<div
|
|
|
|
className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
|
|
|
|
onMouseDown={scrollLeft}
|
|
|
|
>
|
|
|
|
<ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{!atBack && (
|
|
|
|
<div
|
|
|
|
className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
|
|
|
|
onMouseDown={scrollRight}
|
|
|
|
>
|
|
|
|
<ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|