Merge branch 'main' into sell-panel

This commit is contained in:
James Grugett 2022-03-27 18:26:32 -05:00
commit 6c9145c5c6
48 changed files with 993 additions and 162 deletions

View File

@ -29,6 +29,7 @@ export type FullContract<
closeEmailsSent?: number
volume: number
volume24Hours: number
volume7Days: number

View File

@ -34,6 +34,8 @@ export function getNewContract(
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: getFreeAnswerProps(ante)
const volume = outcomeType === 'BINARY' ? 0 : ante
const contract: Contract = removeUndefinedProps({
id,
slug,
@ -54,6 +56,7 @@ export function getNewContract(
lastUpdatedTime: Date.now(),
closeTime,
volume,
volume24Hours: 0,
volume7Days: 0,

View File

@ -1,3 +1,3 @@
export function filterDefined<T>(array: (T | null | undefined)[]) {
return array.filter((item) => item) as T[]
return array.filter((item) => item !== null && item !== undefined) as T[]
}

View File

@ -18,20 +18,10 @@ export function formatWithCommas(amount: number) {
return formatter.format(amount).replace('$', '')
}
const decimalPlaces = (x: number) => Math.ceil(-Math.log10(x)) - 2
export function formatPercent(decimalPercent: number) {
const displayedFigs =
(decimalPercent >= 0.02 && decimalPercent <= 0.98) ||
decimalPercent <= 0 ||
decimalPercent >= 1
? 0
: Math.max(
decimalPlaces(decimalPercent),
decimalPlaces(1 - decimalPercent)
)
return (decimalPercent * 100).toFixed(displayedFigs) + '%'
export function formatPercent(zeroToOne: number) {
// Show 1 decimal place if <2% or >98%, giving more resolution on the tails
const decimalPlaces = zeroToOne < 0.02 || zeroToOne > 0.98 ? 1 : 0
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
}
export function toCamelCase(words: string) {

5
common/util/types.ts Normal file
View File

@ -0,0 +1,5 @@
export type FirstArgument<T> = T extends (arg1: infer U, ...args: any[]) => any
? U
: any
export type Truthy<T> = Exclude<T, undefined | null | false | 0 | ''>

View File

@ -57,7 +57,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
message: 'Requires a free response contract',
}
const { closeTime } = contract
const { closeTime, volume } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
@ -121,6 +121,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
totalShares: newTotalShares,
totalBets: newTotalBets,
answers: [...(contract.answers ?? []), answer],
volume: volume + amount,
})
if (!isFinite(newBalance)) {

View File

@ -49,7 +49,8 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
const { closeTime, outcomeType, mechanism, collectedFees } = contract
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
@ -129,6 +130,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + Math.abs(amount),
})
)

View File

@ -35,7 +35,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
const { closeTime, mechanism, collectedFees } = contract
const { closeTime, mechanism, collectedFees, volume } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
@ -81,6 +81,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
totalShares: newTotalShares,
totalBets: newTotalBets,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + bet.amount,
})
)

View File

@ -21,9 +21,11 @@ export function DailyCountChart(props: {
}))
const data = [{ id: 'Count', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className="w-full"
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
>
<ResponsiveLine
@ -33,10 +35,62 @@ export function DailyCountChart(props: {
type: 'time',
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={width && width >= 800 ? 10 : 0}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
/>
</div>
)
}
export function DailyPercentChart(props: {
startDate: number
dailyPercent: number[]
small?: boolean
}) {
const { dailyPercent, startDate, small } = props
const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = _.zip(dates, dailyPercent).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisLeft={{
format: (value) => `${value}%`,
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"

View File

@ -63,7 +63,7 @@ export function AnswerBetPanel(props: {
if (result?.status === 'success') {
setIsSubmitting(false)
closePanel()
setBetAmount(undefined)
} else {
setError(result?.error || 'Error placing bet')
setIsSubmitting(false)
@ -134,9 +134,11 @@ export function AnswerBetPanel(props: {
</Row>
</Row>
<Row className="items-start justify-between gap-2 text-sm">
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>Payout if chosen</div>
<div>
Estimated <br /> payout if chosen
</div>
<InfoTooltip
text={`Current payout for ${formatWithCommas(
shares

View File

@ -14,8 +14,9 @@ const NUM_LINES = 6
export function AnswersGraph(props: {
contract: FullContract<DPM, FreeResponse>
bets: Bet[]
height?: number
}) {
const { contract } = props
const { contract, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract
const bets = useBets(contract.id) ?? props.bets
@ -86,7 +87,7 @@ export function AnswersGraph(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !width || width >= 800 ? 350 : 225 }}
style={{ height: height ?? (!width || width >= 800 ? 350 : 225) }}
>
<ResponsiveLine
data={data}
@ -137,12 +138,14 @@ const computeProbsByOutcome = (
bets: Bet[],
contract: FullContract<DPM, FreeResponse>
) => {
const { totalBets } = contract
const betsByOutcome = _.groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter)
)
return outcome !== '0' && maxProb > 0.05
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
})
const trackedOutcomes = _.sortBy(

View File

@ -81,7 +81,7 @@ export function CreateAnswerPanel(props: {
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="textarea textarea-bordered w-full"
className="textarea textarea-bordered w-full resize-none"
placeholder="Type your answer..."
rows={1}
maxLength={10000}
@ -117,9 +117,11 @@ export function CreateAnswerPanel(props: {
</Row>
</Row>
<Row className="justify-between gap-2 text-sm">
<Row className="items-center justify-between gap-4 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>Payout if chosen</div>
<div>
Estimated <br /> payout if chosen
</div>
<InfoTooltip
text={`Current payout for ${formatWithCommas(
shares

View File

@ -223,7 +223,7 @@ function BuyPanel(props: {
focusAmountInput()
}, [focusAmountInput])
const tooltip =
const dpmTooltip =
contract.mechanism === 'dpm-2'
? `Current payout for ${formatWithCommas(shares)} / ${formatWithCommas(
shares +
@ -231,7 +231,7 @@ function BuyPanel(props: {
(contract.phantomShares
? contract.phantomShares[betChoice ?? 'YES']
: 0)
)} ${betChoice} shares`
)} ${betChoice ?? 'YES'} shares`
: undefined
return (
<>
@ -263,13 +263,22 @@ function BuyPanel(props: {
</Row>
</Row>
<Row className="items-start justify-between gap-2 text-sm">
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
{contract.mechanism === 'dpm-2' ? (
<>
Estimated
<br /> payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
</>
) : (
<>
Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
</>
)}
</div>
{tooltip && <InfoTooltip text={tooltip} />}
{dpmTooltip && <InfoTooltip text={dpmTooltip} />}
</Row>
<Row className="flex-wrap items-end justify-end gap-2">
<span className="whitespace-nowrap">

View File

@ -37,7 +37,7 @@ import {
resolvedPayout,
} from '../../common/calculate'
type BetSort = 'newest' | 'profit' | 'settled' | 'value'
type BetSort = 'newest' | 'profit' | 'resolutionTime' | 'value' | 'closeTime'
type BetFilter = 'open' | 'closed' | 'resolved' | 'all'
export function BetsList(props: { user: User }) {
@ -91,7 +91,7 @@ export function BetsList(props: { user: User }) {
const contractsInvestment = _.mapValues(contractBets, (bets) => {
return _.sumBy(bets, (bet) => {
if (bet.isSold || bet.sale) return 0
return bet.amount
return bet.amount - (bet.loanAmount ?? 0)
})
})
@ -108,7 +108,8 @@ export function BetsList(props: { user: User }) {
value: (c) => contractsCurrentValue[c.id],
newest: (c) =>
Math.max(...contractBets[c.id].map((bet) => bet.createdTime)),
settled: (c) => c.resolutionTime ?? 0,
resolutionTime: (c) => -(c.resolutionTime ?? c.closeTime ?? Infinity),
closeTime: (c) => -(c.closeTime ?? Infinity),
}
const displayedContracts = _.sortBy(contracts, SORTS[sort])
.reverse()
@ -173,7 +174,8 @@ export function BetsList(props: { user: User }) {
<option value="value">By value</option>
<option value="profit">By profit</option>
<option value="newest">Most recent</option>
<option value="settled">By resolution time</option>
<option value="closeTime">Closing soonest</option>
<option value="resolutionTime">Resolved soonest</option>
</select>
</Row>
</Col>
@ -449,7 +451,7 @@ export function ContractBetsTable(props: {
<div className={clsx('overflow-x-auto', className)}>
{amountRedeemed > 0 && (
<>
<div className="text-gray-500 text-sm pl-2">
<div className="pl-2 text-sm text-gray-500">
{amountRedeemed} YES shares and {amountRedeemed} NO shares
automatically redeemed for {formatMoney(amountRedeemed)}.
</div>
@ -459,7 +461,7 @@ export function ContractBetsTable(props: {
{!isResolved && amountLoaned > 0 && (
<>
<div className="text-gray-500 text-sm pl-2">
<div className="pl-2 text-sm text-gray-500">
You currently have a loan of {formatMoney(amountLoaned)}.
</div>
<Spacer h={4} />
@ -505,7 +507,6 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
shares,
isSold,
isAnte,
loanAmount,
} = bet
const { isResolved, closeTime, mechanism } = contract

View File

@ -21,6 +21,7 @@ import { Spacer } from './layout/spacer'
import { useState } from 'react'
import { TweetButton } from './tweet-button'
import { getProbability } from '../../common/calculate'
import { ShareEmbedButton } from './share-embed-button'
export function ContractCard(props: {
contract: Contract
@ -130,7 +131,7 @@ function AbbrContractDetails(props: {
}) {
const { contract, showHotVolume, showCloseTime } = props
const { volume24Hours, creatorName, creatorUsername, closeTime } = contract
const { liquidityLabel } = contractMetrics(contract)
const { volumeLabel } = contractMetrics(contract)
return (
<Col className={clsx('gap-2 text-sm text-gray-500')}>
@ -161,7 +162,7 @@ function AbbrContractDetails(props: {
) : (
<Row className="gap-1">
{/* <DatabaseIcon className="h-5 w-5" /> */}
{liquidityLabel}
{volumeLabel}
</Row>
)}
</Row>
@ -172,17 +173,17 @@ function AbbrContractDetails(props: {
export function ContractDetails(props: {
contract: Contract
isCreator?: boolean
hideShareButtons?: boolean
}) {
const { contract, isCreator } = props
const { contract, isCreator, hideShareButtons } = props
const { closeTime, creatorName, creatorUsername } = contract
const { liquidityLabel, createdDate, resolvedDate } =
contractMetrics(contract)
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
const tweetText = getTweetText(contract, !!isCreator)
return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
<Row className="flex-wrap items-center gap-x-4 gap-y-2">
<Row className="flex-wrap items-center gap-x-4 gap-y-3">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
@ -230,10 +231,15 @@ export function ContractDetails(props: {
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{liquidityLabel}</div>
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<TweetButton className="self-end" tweetText={tweetText} />
{!hideShareButtons && (
<>
<TweetButton className="self-end" tweetText={tweetText} />
<ShareEmbedButton contract={contract} />
</>
)}
</Row>
</Col>
)
@ -242,8 +248,7 @@ export function ContractDetails(props: {
// String version of the above, to send to the OpenGraph image generator
export function contractTextDetails(contract: Contract) {
const { closeTime, tags } = contract
const { createdDate, resolvedDate, liquidityLabel } =
contractMetrics(contract)
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`)
@ -254,7 +259,7 @@ export function contractTextDetails(contract: Contract) {
closeTime
).format('MMM D, h:mma')}`
: '') +
`${liquidityLabel}` +
`${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '')
)
}

View File

@ -34,43 +34,33 @@ export const ContractOverview = (props: {
return (
<Col className={clsx('mb-6', className)}>
<Row className="justify-between gap-4 px-2">
<Col className="gap-4">
<Col className="gap-4 px-2">
<Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} />
</div>
<Row className="items-center justify-between gap-4">
{(isBinary || resolution) && (
<ResolutionOrChance
className="md:hidden"
contract={contract}
large
/>
)}
{isBinary && tradingAllowed(contract) && (
<BetRow
contract={contract}
className="md:hidden"
labelClassName="hidden"
/>
)}
</Row>
<ContractDetails contract={contract} isCreator={isCreator} />
</Col>
{(isBinary || resolution) && (
<Col className="hidden items-end justify-between md:flex">
{(isBinary || resolution) && (
<ResolutionOrChance
className="items-end"
className="hidden md:flex items-end"
contract={contract}
large
/>
</Col>
)}
</Row>
)}
</Row>
<Row className="md:hidden items-center justify-between gap-4">
{(isBinary || resolution) && (
<ResolutionOrChance contract={contract} />
)}
{isBinary && tradingAllowed(contract) && (
<BetRow contract={contract} labelClassName="hidden" />
)}
</Row>
<ContractDetails contract={contract} isCreator={isCreator} />
</Col>
<Spacer h={4} />

View File

@ -10,8 +10,9 @@ import { useWindowSize } from '../hooks/use-window-size'
export function ContractProbGraph(props: {
contract: FullContract<DPM | CPMM, Binary>
bets: Bet[]
height?: number
}) {
const { contract } = props
const { contract, height } = props
const { resolutionTime, closeTime } = contract
const bets = useBetsWithoutAntes(contract, props.bets).filter(
@ -63,7 +64,7 @@ export function ContractProbGraph(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !width || width >= 800 ? 400 : 250 }}
style={{ height: height ?? (!width || width >= 800 ? 400 : 250) }}
>
<ResponsiveLine
data={data}

View File

@ -7,7 +7,6 @@ import {
contractMetrics,
Contract,
listContracts,
getBinaryProbPercent,
getBinaryProb,
} from '../lib/firebase/contracts'
import { User } from '../lib/firebase/users'
@ -16,7 +15,6 @@ import { SiteLink } from './site-link'
import { ContractCard } from './contract-card'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
import { Answer } from '../../common/answer'
import { getProbability } from '../../common/calculate'
export function ContractsGrid(props: {
contracts: Contract[]
@ -249,9 +247,7 @@ export function SearchableGrid(props: {
({ closeTime }) => closeTime && closeTime > Date.now() !== hideClosed
)
} else if (sort === 'most-traded') {
matches.sort(
(a, b) => contractMetrics(b).truePool - contractMetrics(a).truePool
)
matches.sort((a, b) => b.volume - a.volume)
} else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties.
matches = _.sortBy(matches, ({ volume7Days }) => -1 * volume7Days)

View File

@ -47,6 +47,7 @@ import { BuyButton } from '../yes-no-selector'
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel'
import { useSaveSeenContract } from '../../hooks/use-seen-contracts'
import { User } from '../../../common/user'
export function FeedItems(props: {
contract: Contract
@ -109,7 +110,7 @@ function FeedItem(props: { item: ActivityItem }) {
}
}
function FeedComment(props: {
export function FeedComment(props: {
contract: Contract
comment: Comment
bet: Bet
@ -171,13 +172,14 @@ function RelativeTimestamp(props: { time: number }) {
)
}
function FeedBet(props: {
export function FeedBet(props: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
bettor?: User // If set: reveal bettor identity
}) {
const { contract, bet, hideOutcome, smallAvatar } = props
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
const { id, amount, outcome, createdTime, userId } = bet
const user = useUser()
const isSelf = user?.id === userId
@ -204,6 +206,13 @@ function FeedBet(props: {
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
@ -212,9 +221,10 @@ function FeedBet(props: {
</div>
)}
</div>
<div className={'min-w-0 flex-1 pb-1.5'}>
<div className={'min-w-0 flex-1 py-1.5'}>
<div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : 'A trader'}</span> {bought} {money}
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '}
{bought} {money}
{!hideOutcome && (
<>
{' '}
@ -227,7 +237,7 @@ function FeedBet(props: {
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full"
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
rows={3}
maxLength={MAX_COMMENT_LENGTH}
@ -268,7 +278,7 @@ function EditContract(props: {
return editing ? (
<div className="mt-4">
<Textarea
className="textarea textarea-bordered mb-1 h-24 w-full"
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
rows={3}
value={text}
onChange={(e) => setText(e.target.value || '')}
@ -407,7 +417,7 @@ export function FeedQuestion(props: {
const { contract, showDescription } = props
const { creatorName, creatorUsername, question, resolution, outcomeType } =
contract
const { liquidityLabel } = contractMetrics(contract)
const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
const closeMessage =
@ -435,7 +445,7 @@ export function FeedQuestion(props: {
asked
{/* Currently hidden on mobile; ideally we'd fit this in somewhere. */}
<span className="float-right hidden text-gray-400 sm:inline">
{liquidityLabel}
{volumeLabel}
{closeMessage}
</span>
</div>
@ -697,7 +707,7 @@ function FeedAnswerGroup(props: {
</div>
<Row className="align-items justify-between gap-4">
<span className="text-lg">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>

View File

@ -1,15 +1,16 @@
import clsx from 'clsx'
import { CSSProperties } from 'react'
import { CSSProperties, Ref } from 'react'
export function Col(props: {
children?: any
className?: string
style?: CSSProperties
ref?: Ref<HTMLDivElement>
}) {
const { children, className, style } = props
const { children, className, style, ref } = props
return (
<div className={clsx(className, 'flex flex-col')} style={style}>
<div className={clsx(className, 'flex flex-col')} style={style} ref={ref}>
{children}
</div>
)

View File

@ -14,6 +14,7 @@ export function Leaderboard(props: {
}[]
className?: string
}) {
// TODO: Ideally, highlight your own entry on the leaderboard
const { title, users, columns, className } = props
return (
<div className={clsx('w-full px-1', className)}>

View File

@ -0,0 +1,56 @@
import { Fragment } from 'react'
import { CodeIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import { Contract } from '../../common/contract'
import { contractPath } from '../lib/firebase/contracts'
import { DOMAIN } from '../../common/envs/constants'
import { copyToClipboard } from '../lib/util/copy'
export function ShareEmbedButton(props: { contract: Contract }) {
const { contract } = props
const copyEmbed = () => {
const title = contract.question
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
const embedCode = `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
copyToClipboard(embedCode)
}
return (
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={copyEmbed}
>
<Menu.Button
className="btn btn-xs normal-case"
style={{
backgroundColor: 'white',
border: '2px solid #9ca3af',
color: '#9ca3af', // text-gray-400
}}
>
<CodeIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Embed
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-center absolute left-0 mt-2 w-40 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Item>
<div className="px-2 py-1">Embed code copied!</div>
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)
}

View File

@ -3,7 +3,7 @@ import Link from 'next/link'
export const SiteLink = (props: {
href: string
children: any
children?: any
className?: string
}) => {
const { href, children, className } = props

View File

@ -5,10 +5,7 @@ export function TweetButton(props: { className?: string; tweetText?: string }) {
return (
<a
className={clsx(
'btn btn-xs flex flex-row flex-nowrap border-none normal-case',
className
)}
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
style={{
backgroundColor: 'white',
border: '2px solid #1da1f2',

View File

@ -74,7 +74,10 @@ export const useFilterYourContracts = (
if (yourBetContracts && followedFoldIds) {
// Show default contracts if no folds are followed.
if (followedFoldIds.length === 0)
yourContracts = contracts.filter(includedWithDefaultFeed)
yourContracts = contracts.filter(
(contract) =>
includedWithDefaultFeed(contract) || yourBetContracts.has(contract.id)
)
else
yourContracts = contracts.filter(
(contract) =>

View File

@ -0,0 +1,63 @@
import _ from 'lodash'
import { RefObject, useMemo, useLayoutEffect, useRef, useState } from 'react'
type elem_size =
| { width: number; height: number }
| { width: undefined; height: undefined }
const getSize = (elem: HTMLElement | null) =>
elem
? { width: elem.offsetWidth, height: elem.offsetHeight }
: { width: undefined, height: undefined }
export function useListenElemSize<T extends HTMLElement>(
elemRef: RefObject<T | null>,
callback: (size: elem_size) => void,
debounceMs: number | undefined = undefined
) {
const handleResize = useMemo(() => {
let updateSize = () => {
if (elemRef.current) callback(getSize(elemRef.current))
}
return debounceMs
? _.debounce(updateSize, debounceMs, { leading: false, trailing: true })
: updateSize
}, [callback, elemRef, debounceMs])
let elem = elemRef.current
useLayoutEffect(() => {
if (!elemRef.current) return
const resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(elemRef.current)
return () => resizeObserver.disconnect()
}, [elemRef, elem, handleResize])
}
export function useMeasureSize(debounceMs: number | undefined = undefined) {
const elemRef = useRef<HTMLElement | null>(null)
const [size, setSize] = useState(() => getSize(null))
const sizeRef = useRef<elem_size>(size)
const setSizeIfDifferent = (newSize: typeof size) => {
if (newSize?.height !== size?.height || newSize?.width !== size?.width) {
sizeRef.current = newSize
setSize(newSize)
}
}
useListenElemSize(elemRef, setSizeIfDifferent, debounceMs)
const setElem = (elem: HTMLElement | null) => {
elemRef.current = elem
if (elem) {
setSizeIfDifferent(getSize(elem))
}
}
return { setElem, elemRef, sizeRef, ...size }
}

View File

@ -69,7 +69,9 @@ export function useQueryAndSortParams(options?: {
if (router.isReady && !sort && shouldLoadFromStorage) {
const localSort = localStorage.getItem(MARKETS_SORT) as Sort
if (localSort) {
setSort(localSort)
router.query.s = localSort
// Use replace to not break navigating back.
router.replace(router, undefined, { shallow: true })
}
}
})

View File

@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { PrivateUser, User } from '../../common/user'
import { listenForAllUsers, listenForPrivateUsers } from '../lib/firebase/users'
import {
getUser,
listenForAllUsers,
listenForPrivateUsers,
} from '../lib/firebase/users'
export const useUsers = () => {
const [users, setUsers] = useState<User[]>([])
@ -12,6 +16,18 @@ export const useUsers = () => {
return users
}
export const useUserById = (userId?: string) => {
const [user, setUser] = useState<User | undefined>(undefined)
useEffect(() => {
if (userId) {
getUser(userId).then(setUser)
}
}, [userId])
return user
}
export const usePrivateUsers = () => {
const [users, setUsers] = useState<PrivateUser[]>([])

View File

@ -22,7 +22,6 @@ import { getDpmProbability } from '../../../common/calculate-dpm'
import { createRNG, shuffle } from '../../../common/util/random'
import { getCpmmProbability } from '../../../common/calculate-cpmm'
import { formatMoney, formatPercent } from '../../../common/util/format'
import { getCpmmLiquidity } from '../../../common/calculate-cpmm'
export type { Contract }
export function contractPath(contract: Contract) {
@ -30,9 +29,7 @@ export function contractPath(contract: Contract) {
}
export function contractMetrics(contract: Contract) {
const { pool, createdTime, resolutionTime, isResolved } = contract
const truePool = _.sum(Object.values(pool))
const { createdTime, resolutionTime, isResolved } = contract
const createdDate = dayjs(createdTime).format('MMM D')
@ -40,14 +37,9 @@ export function contractMetrics(contract: Contract) {
? dayjs(resolutionTime).format('MMM D')
: undefined
const liquidityLabel =
contract.mechanism === 'dpm-2'
? `${formatMoney(truePool)} pool`
: `${formatMoney(
contract.totalLiquidity ?? getCpmmLiquidity(pool, contract.p)
)} liquidity`
const volumeLabel = `${formatMoney(contract.volume)} volume`
return { truePool, liquidityLabel, createdDate, resolvedDate }
return { volumeLabel, createdDate, resolvedDate }
}
export function getBinaryProb(contract: FullContract<any, Binary>) {

View File

@ -126,6 +126,16 @@ export async function uploadData(
return await getDownloadURL(uploadRef)
}
export async function listUsers(userIds: string[]) {
if (userIds.length > 10) {
throw new Error('Too many users requested at once; Firestore limits to 10')
}
const userCollection = collection(db, 'users')
const q = query(userCollection, where('id', 'in', userIds))
const docs = await getDocs(q)
return docs.docs.map((doc) => doc.data() as User)
}
export async function listAllUsers() {
const userCollection = collection(db, 'users')
const q = query(userCollection)

38
web/lib/util/copy.ts Normal file
View File

@ -0,0 +1,38 @@
// From: https://stackoverflow.com/a/33928558/1592933
// Copies a string to the clipboard. Must be called from within an
// event handler such as click. May return false if it failed, but
// this is not always possible. Browser support for Chrome 43+,
// Firefox 42+, Safari 10+, Edge and Internet Explorer 10+.
// Internet Explorer: The clipboard feature may be disabled by
// an administrator. By default a prompt is shown the first
// time the clipboard is used (per session).
export function copyToClipboard(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
} else if (
(window as any).clipboardData &&
(window as any).clipboardData.setData
) {
console.log('copy 2')
// Internet Explorer-specific code path to prevent textarea being shown while dialog is visible.
return (window as any).clipboardData.setData('Text', text)
} else if (
document.queryCommandSupported &&
document.queryCommandSupported('copy')
) {
console.log('copy 3')
var textarea = document.createElement('textarea')
textarea.textContent = text
textarea.style.position = 'fixed' // Prevent scrolling to bottom of page in Microsoft Edge.
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy') // Security exception may be thrown by some browsers.
} catch (ex) {
console.warn('Copy to clipboard failed.', ex)
return prompt('Copy to clipboard: Ctrl+C, Enter', text)
} finally {
document.body.removeChild(textarea)
}
}
}

15
web/next-sitemap.js Normal file
View File

@ -0,0 +1,15 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://manifold.markets',
changefreq: 'hourly',
priority: 0.7, // Set high priority by default
exclude: ['/admin', '/server-sitemap.xml'],
generateRobotsTxt: true,
robotsTxtOptions: {
additionalSitemaps: [
'https://manifold.markets/server-sitemap.xml', // <==== Add here
],
},
// Other options: https://github.com/iamvishnusankar/next-sitemap#configuration-options
}

View File

@ -1,5 +1,4 @@
const API_DOCS_URL =
'https://manifoldmarkets.notion.site/Manifold-Markets-API-5e7d0aef4dcf452bb04b319e178fabc5'
const API_DOCS_URL = 'https://docs.manifold.markets/api'
/** @type {import('next').NextConfig} */
module.exports = {

View File

@ -12,7 +12,8 @@
"start": "next start",
"lint": "next lint",
"format": "npx prettier --write .",
"prepare": "cd .. && husky install web/.husky"
"prepare": "cd .. && husky install web/.husky",
"postbuild": "next-sitemap"
},
"dependencies": {
"@headlessui/react": "1.4.2",
@ -44,6 +45,7 @@
"eslint-config-next": "12.0.4",
"husky": "7.0.4",
"lint-staged": "12.1.3",
"next-sitemap": "^2.5.14",
"postcss": "8.3.5",
"prettier": "2.5.0",
"prettier-plugin-tailwindcss": "^0.1.5",

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useContractWithPreload } from '../../hooks/use-contract'
import { ContractOverview } from '../../components/contract-overview'
@ -10,7 +10,7 @@ import { ContractBetsTable, MyBetsSummary } from '../../components/bets-list'
import { useBets } from '../../hooks/use-bets'
import { Title } from '../../components/title'
import { Spacer } from '../../components/layout/spacer'
import { User } from '../../lib/firebase/users'
import { listUsers, User } from '../../lib/firebase/users'
import {
Contract,
getContractFromSlug,
@ -30,6 +30,12 @@ import { listAllAnswers } from '../../lib/firebase/answers'
import { Answer } from '../../../common/answer'
import { AnswersPanel } from '../../components/answers/answers-panel'
import { fromPropz, usePropz } from '../../hooks/use-propz'
import { Leaderboard } from '../../components/leaderboard'
import _ from 'lodash'
import { calculatePayout, resolvedPayout } from '../../../common/calculate'
import { formatMoney } from '../../../common/util/format'
import { FeedBet, FeedComment } from '../../components/feed/feed-items'
import { useUserById } from '../../hooks/use-users'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -147,6 +153,19 @@ export default function ContractPage(props: {
)}
</ContractOverview>
{contract.isResolved && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2">
<ContractLeaderboard contract={contract} bets={bets} />
<ContractTopTrades
contract={contract}
bets={bets}
comments={comments}
/>
</div>
<Spacer h={12} />
</>
)}
<BetsSection contract={contract} user={user ?? null} bets={bets} />
</div>
@ -195,6 +214,122 @@ function BetsSection(props: {
)
}
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
// Create a map of userIds to total profits (including sales)
const betsByUser = _.groupBy(bets, 'userId')
const userProfits = _.mapValues(betsByUser, (bets) =>
_.sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = _.entries(userProfits)
.sort(([i1, p1], [i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = _.sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
}) {
const { contract, bets, comments } = props
const commentsById = _.keyBy(comments, 'id')
const betsById = _.keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = _.sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = _.sortBy(comments, (c) => -profitById[c.betId])[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
bet={betsById[topCommentId]}
hideOutcome={false}
truncate={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
bettor={topBettor}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}
const getOpenGraphProps = (contract: Contract) => {
const { resolution, question, creatorName, creatorUsername, outcomeType } =
contract

View File

@ -1,7 +1,12 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import _ from 'lodash'
import { useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from '../../common/envs/constants'
import { DailyCountChart } from '../components/analytics/charts'
import {
DailyCountChart,
DailyPercentChart,
} from '../components/analytics/charts'
import { Col } from '../components/layout/col'
import { Spacer } from '../components/layout/spacer'
import { Page } from '../components/page'
@ -13,7 +18,7 @@ import { getDailyContracts } from '../lib/firebase/contracts'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
const numberOfDays = 80
const numberOfDays = 90
const today = dayjs(dayjs().format('YYYY-MM-DD'))
const startDate = today.subtract(numberOfDays, 'day')
@ -29,22 +34,70 @@ export async function getStaticPropz() {
)
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
const dailyActiveUsers = _.zip(dailyContracts, dailyBets, dailyComments).map(
const dailyUserIds = _.zip(dailyContracts, dailyBets, dailyComments).map(
([contracts, bets, comments]) => {
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
const betUserIds = (bets ?? []).map((bet) => bet.userId)
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
return _.uniq([...creatorIds, ...betUserIds, commentUserIds]).length
return _.uniq([...creatorIds, ...betUserIds, ...commentUserIds])
}
)
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 30)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
}
const activeTwoWeeksAgo = new Set<string>()
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
}
const activeLastWeek = new Set<string>()
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const retainedCount = _.sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
return {
props: {
startDate: startDate.valueOf(),
dailyActiveUsers,
weeklyActiveUsers,
monthlyActiveUsers,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
weekOnWeekRetention,
},
revalidate: 12 * 60 * 60, // regenerate after half a day
}
@ -53,16 +106,22 @@ export async function getStaticPropz() {
export default function Analytics(props: {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
weekOnWeekRetention: number[]
}) {
props = usePropz(props, getStaticPropz) ?? {
startDate: 0,
dailyActiveUsers: [],
weeklyActiveUsers: [],
monthlyActiveUsers: [],
dailyBetCounts: [],
dailyContractCounts: [],
dailyCommentCounts: [],
weekOnWeekRetention: [],
}
return (
<Page>
@ -73,12 +132,15 @@ export default function Analytics(props: {
)
}
function CustomAnalytics(props: {
export function CustomAnalytics(props: {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
weekOnWeekRetention: number[]
}) {
const {
startDate,
@ -86,46 +148,165 @@ function CustomAnalytics(props: {
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
weeklyActiveUsers,
monthlyActiveUsers,
weekOnWeekRetention,
} = props
return (
<Col>
<Col className="px-2 sm:px-0">
<Title text="Active users" />
<DailyCountChart dailyCounts={dailyActiveUsers} startDate={startDate} />
<p className="text-gray-500">
An active user is a user who has traded in, commented on, or created a
market.
</p>
<Spacer h={4} />
<Title text="Bets count" />
<DailyCountChart
dailyCounts={dailyBetCounts}
<Tabs
defaultIndex={1}
tabs={[
{
title: 'Daily',
content: (
<DailyCountChart
dailyCounts={dailyActiveUsers}
startDate={startDate}
small
/>
),
},
{
title: 'Weekly',
content: (
<DailyCountChart
dailyCounts={weeklyActiveUsers}
startDate={startDate}
small
/>
),
},
{
title: 'Monthly',
content: (
<DailyCountChart
dailyCounts={monthlyActiveUsers}
startDate={startDate}
small
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="Week-on-week retention" />
<p className="text-gray-500">
Out of all active users 2 weeks ago, how many came back last week?
</p>
<DailyPercentChart
dailyPercent={weekOnWeekRetention}
startDate={startDate}
small
/>
<Spacer h={8} />
<Title text="Markets count" />
<DailyCountChart
dailyCounts={dailyContractCounts}
startDate={startDate}
small
/>
<Title text="Comments count" />
<DailyCountChart
dailyCounts={dailyCommentCounts}
startDate={startDate}
small
<Title text="Daily activity" />
<Tabs
defaultIndex={0}
tabs={[
{
title: 'Trades',
content: (
<DailyCountChart
dailyCounts={dailyBetCounts}
startDate={startDate}
small
/>
),
},
{
title: 'Markets created',
content: (
<DailyCountChart
dailyCounts={dailyContractCounts}
startDate={startDate}
small
/>
),
},
{
title: 'Comments',
content: (
<DailyCountChart
dailyCounts={dailyCommentCounts}
startDate={startDate}
small
/>
),
},
]}
/>
<Spacer h={8} />
</Col>
)
}
function FirebaseAnalytics() {
// Edit dashboard at https://datastudio.google.com/u/0/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3/edit
type Tab = {
title: string
content: JSX.Element
}
function Tabs(props: { tabs: Tab[]; defaultIndex: number }) {
const { tabs, defaultIndex } = props
const [activeTab, setActiveTab] = useState(tabs[defaultIndex])
return (
<iframe
className="w-full"
height={2200}
src="https://datastudio.google.com/embed/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3"
frameBorder="0"
style={{ border: 0 }}
allowFullScreen
/>
<div>
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<a
key={tab.title}
href="#"
className={clsx(
tab.title === activeTab.title
? 'bg-gray-100 text-gray-700'
: 'text-gray-500 hover:text-gray-700',
'rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={tab.title === activeTab.title ? 'page' : undefined}
onClick={(e) => {
console.log('clicked')
e.preventDefault()
setActiveTab(tab)
}}
>
{tab.title}
</a>
))}
</nav>
<div className="mt-4">{activeTab.content}</div>
</div>
)
}
export function FirebaseAnalytics() {
// Edit dashboard at https://datastudio.google.com/u/0/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3/edit
return (
<>
<Title text="Google Analytics" />
<p className="text-gray-500">
Less accurate; includes all viewers (not just signed-in users).
</p>
<Spacer h={4} />
<iframe
className="w-full"
height={2200}
src="https://datastudio.google.com/embed/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3"
frameBorder="0"
style={{ border: 0 }}
allowFullScreen
/>
</>
)
}

View File

@ -184,7 +184,7 @@ export function NewContract(props: { question: string; tag?: string }) {
<InfoTooltip text="Optional. Describe how you will resolve this market." />
</label>
<Textarea
className="textarea textarea-bordered w-full"
className="textarea textarea-bordered w-full resize-none"
rows={3}
maxLength={MAX_DESCRIPTION_LENGTH}
placeholder={descriptionPlaceholder}

View File

@ -0,0 +1,145 @@
import { Bet } from '../../../../common/bet'
import {
Contract,
DPM,
FreeResponse,
FullContract,
} from '../../../../common/contract'
import { DOMAIN } from '../../../../common/envs/constants'
import { AnswersGraph } from '../../../components/answers/answers-graph'
import {
ResolutionOrChance,
ContractDetails,
} from '../../../components/contract-card'
import { ContractProbGraph } from '../../../components/contract-prob-graph'
import { Col } from '../../../components/layout/col'
import { Row } from '../../../components/layout/row'
import { Spacer } from '../../../components/layout/spacer'
import { Linkify } from '../../../components/linkify'
import { SiteLink } from '../../../components/site-link'
import { useContractWithPreload } from '../../../hooks/use-contract'
import { useMeasureSize } from '../../../hooks/use-measure-size'
import { fromPropz, usePropz } from '../../../hooks/use-propz'
import { useWindowSize } from '../../../hooks/use-window-size'
import { listAllBets } from '../../../lib/firebase/bets'
import {
contractPath,
getContractFromSlug,
} from '../../../lib/firebase/contracts'
import Custom404 from '../../404'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
params: { username: string; contractSlug: string }
}) {
const { username, contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : []
return {
props: {
contract,
username,
slug: contractSlug,
bets,
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
export default function ContractEmbedPage(props: {
contract: Contract | null
username: string
bets: Bet[]
slug: string
}) {
props = usePropz(props, getStaticPropz) ?? {
contract: null,
username: '',
bets: [],
slug: '',
}
const contract = useContractWithPreload(props.slug, props.contract)
const { bets } = props
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
if (!contract) {
return <Custom404 />
}
return <ContractEmbed contract={contract} bets={bets} />
}
function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const { question, resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const href = `https://${DOMAIN}${contractPath(contract)}`
const { height: windowHeight } = useWindowSize()
const { setElem, height: topSectionHeight } = useMeasureSize()
const paddingBottom = 8
const graphHeight =
windowHeight && topSectionHeight
? windowHeight - topSectionHeight - paddingBottom
: 0
return (
<Col className="w-full flex-1 bg-white">
<div className="flex flex-col relative pt-2" ref={setElem}>
<SiteLink
className="absolute top-0 left-0 w-full h-full z-20"
href={href}
/>
<div className="px-3 text-xl md:text-2xl text-indigo-700">
<Linkify text={question} />
</div>
<Spacer h={3} />
<Row className="items-center justify-between gap-4 px-2">
<ContractDetails
contract={contract}
isCreator={false}
hideShareButtons
/>
{(isBinary || resolution) && (
<ResolutionOrChance contract={contract} />
)}
</Row>
<Spacer h={2} />
</div>
<div className="mx-1" style={{ paddingBottom }}>
{isBinary ? (
<ContractProbGraph
contract={contract}
bets={bets}
height={graphHeight}
/>
) : (
<AnswersGraph
contract={contract as FullContract<DPM, FreeResponse>}
bets={bets}
height={graphHeight}
/>
)}
</div>
</Col>
)
}

View File

@ -0,0 +1,21 @@
import { FirstArgument } from '../../../common/util/types'
import { Col } from '../../components/layout/col'
import { Spacer } from '../../components/layout/spacer'
import { fromPropz } from '../../hooks/use-propz'
import Analytics, {
CustomAnalytics,
FirebaseAnalytics,
getStaticPropz,
} from '../analytics'
export const getStaticProps = fromPropz(getStaticPropz)
export default function AnalyticsEmbed(props: FirstArgument<typeof Analytics>) {
return (
<Col className="w-full px-2 bg-white">
<CustomAnalytics {...props} />
<Spacer h={8} />
<FirebaseAnalytics />
</Col>
)
}

View File

@ -93,8 +93,7 @@ export default function Folds(props: {
<div className="mb-6 text-gray-500">
Communities on Manifold are centered around a collection of
markets. Follow a community to personalize your feed and receive
relevant updates.
markets. Follow a community to personalize your feed!
</div>
<input

View File

@ -6,6 +6,7 @@ import { Page } from '../components/page'
import { FeedPromo } from '../components/feed-create'
import { Col } from '../components/layout/col'
import { useUser } from '../hooks/use-user'
import { SiteLink } from '../components/site-link'
export async function getStaticProps() {
const hotContracts = (await getHotContracts().catch(() => [])) ?? []
@ -31,6 +32,12 @@ const Home = (props: { hotContracts: Contract[] }) => {
<Col className="items-center">
<Col className="max-w-3xl">
<FeedPromo hotContracts={hotContracts ?? []} />
<p className="mt-6 text-gray-500">
View{' '}
<SiteLink href="/markets" className="font-bold text-gray-700">
all markets
</SiteLink>
</p>
</Col>
</Col>
</Page>

View File

@ -195,7 +195,7 @@ ${TEST_VALUE}
<Textarea
placeholder="e.g. This market is part of the ACX predictions for 2022..."
className="input"
className="input resize-none"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
/>

View File

@ -41,7 +41,7 @@ function EditUserField(props: {
{field === 'bio' ? (
<Textarea
className="textarea textarea-bordered w-full"
className="textarea textarea-bordered w-full resize-none"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={updateField}

View File

@ -0,0 +1,21 @@
import { GetServerSideProps } from 'next'
import { getServerSideSitemap } from 'next-sitemap'
import { DOMAIN } from '../../common/envs/constants'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Fetching data from https://docs.manifold.markets/api
const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
const liteMarkets = await response.json()
const fields = liteMarkets.map((liteMarket: any) => ({
// See https://www.sitemaps.org/protocol.html
loc: liteMarket.url,
changefreq: 'hourly',
priority: 0.2, // Individual markets aren't that important
// TODO: Add `lastmod` aka last modified time
}))
return getServerSideSitemap(ctx, fields)
}
// Default export to prevent next.js errors
export default function Sitemap() {}

10
web/public/robots.txt Normal file
View File

@ -0,0 +1,10 @@
# *
User-agent: *
Allow: /
# Host
Host: https://manifold.markets
# Sitemaps
Sitemap: https://manifold.markets/sitemap.xml
Sitemap: https://manifold.markets/server-sitemap.xml

19
web/public/sitemap-0.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/about</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/account</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/add-funds</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/analytics</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/create</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/embed/analytics</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/folds</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/landing-page</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/make-predictions</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/profile</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/simulator</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/trades</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
</urlset>

4
web/public/sitemap.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://manifold.markets/sitemap-0.xml</loc></sitemap>
</sitemapindex>

View File

@ -72,6 +72,11 @@
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
"@corex/deepmerge@^2.6.148":
version "2.6.148"
resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5"
integrity sha512-6QMz0/2h5C3ua51iAnXMPWFbb1QOU1UvSM4bKBw5mzdT+WtLgjbETBBIQZ+Sh9WvEcGwlAt/DEdRpIC3XlDBMA==
"@eslint/eslintrc@^0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
@ -4322,6 +4327,11 @@ minimist@^1.1.1, minimist@^1.2.0:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mri@^1.1.5:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@ -4373,6 +4383,14 @@ netmask@^1.0.6:
resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
next-sitemap@^2.5.14:
version "2.5.14"
resolved "https://registry.yarnpkg.com/next-sitemap/-/next-sitemap-2.5.14.tgz#f196c90d4aef8444c6eb7266875bf2179a515bb7"
integrity sha512-aJmxGmoE23NClCi1P6KpHov9DUieF/ZZbfGpTiruOYCq4nKu8Q4masOuswlOl3nNKZa0C3u4JG+TPubjslYH9A==
dependencies:
"@corex/deepmerge" "^2.6.148"
minimist "^1.2.6"
next@12.0.7:
version "12.0.7"
resolved "https://registry.yarnpkg.com/next/-/next-12.0.7.tgz#33ebf229b81b06e583ab5ae7613cffe1ca2103fc"