From bc042446221a806952ce3186a2cb90a9a675049b Mon Sep 17 00:00:00 2001
From: ingawei <46611122+ingawei@users.noreply.github.com>
Date: Fri, 8 Sep 2023 16:20:14 -0700
Subject: [PATCH] added preview view (#2030)
* added preview view
---
web/components/preview/preview-bet-button.tsx | 27 +
web/components/preview/preview-bet-panel.tsx | 432 ++++++++++++++
.../preview/preview-contract-overview.tsx | 355 +++++++++++
.../preview/preview-contract-price.tsx | 164 ++++++
.../preview/preview-yes-no-selector.tsx | 198 +++++++
.../preview/[username]/[contractSlug].tsx | 549 ++++++++++++++++++
6 files changed, 1725 insertions(+)
create mode 100644 web/components/preview/preview-bet-button.tsx
create mode 100644 web/components/preview/preview-bet-panel.tsx
create mode 100644 web/components/preview/preview-contract-overview.tsx
create mode 100644 web/components/preview/preview-contract-price.tsx
create mode 100644 web/components/preview/preview-yes-no-selector.tsx
create mode 100644 web/pages/preview/[username]/[contractSlug].tsx
diff --git a/web/components/preview/preview-bet-button.tsx b/web/components/preview/preview-bet-button.tsx
new file mode 100644
index 0000000000..a19cc7a1b7
--- /dev/null
+++ b/web/components/preview/preview-bet-button.tsx
@@ -0,0 +1,27 @@
+import {
+ BinaryContract,
+ CPMMBinaryContract,
+ PseudoNumericContract,
+ StonkContract,
+} from 'common/contract'
+import { UserBetsSummary } from 'web/components/bet/bet-summary'
+import { User } from 'web/lib/firebase/users'
+import { Col } from '../layout/col'
+import { PreviewBuyPanel } from './preview-bet-panel'
+
+export function PreviewSignedInBinaryMobileBetting(props: {
+ contract: BinaryContract | PseudoNumericContract | StonkContract
+ user: User | null | undefined
+}) {
+ const { contract, user } = props
+
+ return (
+
+
+
+ )
+}
diff --git a/web/components/preview/preview-bet-panel.tsx b/web/components/preview/preview-bet-panel.tsx
new file mode 100644
index 0000000000..b76cae8cee
--- /dev/null
+++ b/web/components/preview/preview-bet-panel.tsx
@@ -0,0 +1,432 @@
+import { CheckIcon } from '@heroicons/react/solid'
+import clsx from 'clsx'
+import { sumBy } from 'lodash'
+import { useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+
+import { LimitBet } from 'common/bet'
+import {
+ CPMMBinaryContract,
+ CPMMMultiContract,
+ PseudoNumericContract,
+ StonkContract,
+} from 'common/contract'
+import { computeCpmmBet } from 'common/new-bet'
+import {
+ formatLargeNumber,
+ formatMoney,
+ formatOutcomeLabel,
+ formatPercent,
+} from 'common/util/format'
+import { APIError, placeBet } from 'web/lib/firebase/api'
+import { User, firebaseLogin } from 'web/lib/firebase/users'
+import { Col } from '../layout/col'
+import { Row } from '../layout/row'
+import { BuyAmountInput } from '../widgets/amount-input'
+
+import { Answer } from 'common/answer'
+import { getCpmmProbability } from 'common/calculate-cpmm'
+import { calculateCpmmMultiArbitrageBet } from 'common/calculate-cpmm-arbitrage'
+import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
+import { STONK_NO, STONK_YES, getStonkDisplayShares } from 'common/stonk'
+import { SINGULAR_BET } from 'common/user'
+import { removeUndefinedProps } from 'common/util/object'
+import { InfoTooltip } from 'web/components/widgets/info-tooltip'
+import { useFocus } from 'web/hooks/use-focus'
+import { track, withTracking } from 'web/lib/service/analytics'
+import { isAndroid, isIOS } from 'web/lib/util/device'
+import { useUnfilledBetsAndBalanceByUserId } from '../../hooks/use-bets'
+import { Button } from '../buttons/button'
+import { WarningConfirmationButton } from '../buttons/warning-confirmation-button'
+import { PreviewYesNoSelector } from './preview-yes-no-selector'
+
+export type binaryOutcomes = 'YES' | 'NO' | undefined
+
+export function PreviewBuyPanel(props: {
+ contract:
+ | CPMMBinaryContract
+ | PseudoNumericContract
+ | StonkContract
+ | CPMMMultiContract
+ multiProps?: { answers: Answer[]; answerToBuy: Answer }
+ user: User | null | undefined
+ inModal: boolean
+ onBuySuccess?: () => void
+ singularView?: 'YES' | 'NO' | 'LIMIT'
+ initialOutcome?: binaryOutcomes | 'LIMIT'
+ location?: string
+}) {
+ const {
+ contract,
+ multiProps,
+ user,
+ onBuySuccess,
+ singularView,
+ initialOutcome,
+ location = 'bet panel',
+ inModal,
+ } = props
+
+ const isCpmmMulti = contract.mechanism === 'cpmm-multi-1'
+ if (isCpmmMulti && !multiProps) {
+ throw new Error('multiProps must be defined for cpmm-multi-1')
+ }
+ const shouldAnswersSumToOne =
+ 'shouldAnswersSumToOne' in contract ? contract.shouldAnswersSumToOne : false
+
+ const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
+ const isStonk = contract.outcomeType === 'STONK'
+ const [option, setOption] = useState(initialOutcome)
+ const { unfilledBets: allUnfilledBets, balanceByUserId } =
+ useUnfilledBetsAndBalanceByUserId(contract.id)
+
+ const unfilledBetsMatchingAnswer = allUnfilledBets.filter(
+ (b) => b.answerId === multiProps?.answerToBuy?.id
+ )
+ const unfilledBets =
+ isCpmmMulti && !shouldAnswersSumToOne
+ ? // Always filter to answer for non-sum-to-one cpmm multi
+ unfilledBetsMatchingAnswer
+ : allUnfilledBets
+
+ const outcome = option === 'LIMIT' ? undefined : option
+ const seeLimit = option === 'LIMIT'
+
+ const [betAmount, setBetAmount] = useState(10)
+ const [error, setError] = useState()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const [inputRef, focusAmountInput] = useFocus()
+
+ useEffect(() => {
+ if (initialOutcome) {
+ setOption(initialOutcome)
+ }
+ }, [initialOutcome])
+
+ function onOptionChoice(choice: 'YES' | 'NO' | 'LIMIT') {
+ if (option === choice && !initialOutcome) {
+ setOption(undefined)
+ } else {
+ setOption(choice)
+ }
+ if (!isIOS() && !isAndroid()) {
+ focusAmountInput()
+ }
+ }
+
+ function onBetChange(newAmount: number | undefined) {
+ setBetAmount(newAmount)
+ if (!outcome) {
+ setOption('YES')
+ }
+ }
+
+ async function submitBet() {
+ if (!user || !betAmount) return
+
+ setError(undefined)
+ setIsSubmitting(true)
+ placeBet(
+ removeUndefinedProps({
+ outcome,
+ amount: betAmount,
+ contractId: contract.id,
+ answerId: multiProps?.answerToBuy.id,
+ })
+ )
+ .then((r) => {
+ console.log('placed bet. Result:', r)
+ setIsSubmitting(false)
+ setBetAmount(undefined)
+ if (onBuySuccess) onBuySuccess()
+ else {
+ toast('Trade submitted!', {
+ icon: ,
+ })
+ }
+ })
+ .catch((e) => {
+ if (e instanceof APIError) {
+ setError(e.toString())
+ } else {
+ console.error(e)
+ setError('Error placing bet')
+ }
+ setIsSubmitting(false)
+ })
+
+ track('bet', {
+ location,
+ outcomeType: contract.outcomeType,
+ slug: contract.slug,
+ contractId: contract.id,
+ amount: betAmount,
+ outcome,
+ isLimitOrder: false,
+ answerId: multiProps?.answerToBuy.id,
+ })
+ }
+
+ const betDisabled =
+ isSubmitting || !betAmount || !!error || outcome === undefined
+
+ let currentPayout: number
+ let probBefore: number
+ let probAfter: number
+ if (isCpmmMulti && multiProps && contract.shouldAnswersSumToOne) {
+ const { answers, answerToBuy } = multiProps
+ const { newBetResult } = calculateCpmmMultiArbitrageBet(
+ answers,
+ answerToBuy,
+ outcome ?? 'YES',
+ betAmount ?? 0,
+ undefined,
+ unfilledBets,
+ balanceByUserId
+ )
+ const { pool, p } = newBetResult.cpmmState
+ currentPayout = sumBy(newBetResult.takers, 'shares')
+ probBefore = answerToBuy.prob
+ probAfter = getCpmmProbability(pool, p)
+ } else {
+ const cpmmState = isCpmmMulti
+ ? {
+ pool: {
+ YES: multiProps!.answerToBuy.poolYes,
+ NO: multiProps!.answerToBuy.poolNo,
+ },
+ p: 0.5,
+ }
+ : { pool: contract.pool, p: contract.p }
+
+ const result = computeCpmmBet(
+ cpmmState,
+ outcome ?? 'YES',
+ betAmount ?? 0,
+ undefined,
+ unfilledBets,
+ balanceByUserId
+ )
+ currentPayout = result.shares
+ probBefore = result.probBefore
+ probAfter = result.probAfter
+ }
+
+ const probStayedSame = formatPercent(probAfter) === formatPercent(probBefore)
+ const probChange = Math.abs(probAfter - probBefore)
+ const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
+ const currentReturnPercent = formatPercent(currentReturn)
+
+ const rawDifference = Math.abs(
+ getMappedValue(contract, probAfter) - getMappedValue(contract, probBefore)
+ )
+ const displayedDifference = isPseudoNumeric
+ ? formatLargeNumber(rawDifference)
+ : formatPercent(rawDifference)
+
+ const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
+
+ // warnings
+ const highBankrollSpend =
+ (betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
+ const highProbMove =
+ (betAmount ?? 0) > 10 && probChange > 0.299 && bankrollFraction <= 1
+
+ const warning = highBankrollSpend
+ ? `You might not want to spend ${formatPercent(
+ bankrollFraction
+ )} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
+ user?.balance ?? 0
+ )}`
+ : highProbMove
+ ? `Are you sure you want to move the probability by ${displayedDifference}?`
+ : undefined
+
+ const displayError = !!outcome
+ const selected = seeLimit ? 'LIMIT' : outcome
+
+ return (
+
+
+ {
+ onOptionChoice(choice)
+ }}
+ yesLabel={isPseudoNumeric ? 'HIGHER' : isStonk ? STONK_YES : 'YES'}
+ noLabel={isPseudoNumeric ? 'LOWER' : isStonk ? STONK_NO : 'NO'}
+ />
+ {/* {!!user && !isStonk && (
+
+ )} */}
+
+
+ Amount
+
+
+
+
+
+
+ {isPseudoNumeric || isStonk ? (
+ 'Shares'
+ ) : (
+ <>Payout if {outcome ?? 'YES'}>
+ )}
+
+
+
+ {isStonk
+ ? getStonkDisplayShares(contract, currentPayout, 2)
+ : isPseudoNumeric
+ ? Math.floor(currentPayout)
+ : formatMoney(currentPayout)}
+
+
+ {isStonk || isPseudoNumeric ? '' : ' +' + currentReturnPercent}
+
+
+
+
+
+
+ {isPseudoNumeric
+ ? 'Estimated value'
+ : isStonk
+ ? 'New stock price'
+ : 'New probability'}
+
+ {!isPseudoNumeric && !isStonk && (
+
+ )}
+
+ {probStayedSame ? (
+
+ {getFormattedMappedValue(contract, probBefore)}
+
+ ) : (
+
+
+ {getFormattedMappedValue(contract, probAfter)}
+
+
+ {isPseudoNumeric ? (
+ <>>
+ ) : (
+ <>
+ {' '}
+ {outcome != 'NO' && '+'}
+ {getFormattedMappedValue(
+ contract,
+ probAfter - probBefore
+ )}
+ >
+ )}
+
+
+ )}
+
+
+
+ {user ? (
+
+ ) : (
+
+ )}
+
+ {/*
+
+
+
+ */}
+ {/* Stonks don't allow limit orders but users may have them from before the conversion*/}
+ {/* {isStonk && unfilledBets.length > 0 && (
+
+ )} */}
+
+ )
+}
diff --git a/web/components/preview/preview-contract-overview.tsx b/web/components/preview/preview-contract-overview.tsx
new file mode 100644
index 0000000000..2d2c2e0fa5
--- /dev/null
+++ b/web/components/preview/preview-contract-overview.tsx
@@ -0,0 +1,355 @@
+import { memo, useState } from 'react'
+import { Bet } from 'common/bet'
+import { HistoryPoint } from 'common/chart'
+import {
+ BinaryContract,
+ CPMMStonkContract,
+ Contract,
+ MultiContract,
+ NumericContract,
+ PseudoNumericContract,
+} from 'common/contract'
+import { YES_GRAPH_COLOR } from 'common/envs/constants'
+import { NumericContractChart } from '../charts/contract/numeric'
+import { BinaryContractChart } from '../charts/contract/binary'
+import { ChoiceContractChart, MultiPoint } from '../charts/contract/choice'
+import { PseudoNumericContractChart } from '../charts/contract/pseudo-numeric'
+import { useViewScale } from 'web/components/charts/generic-charts'
+import {
+ BinaryResolutionOrChance,
+ NumericResolutionOrExpectation,
+ PseudoNumericResolutionOrExpectation,
+ StonkPrice,
+} from 'web/components/contract/contract-price'
+import { SizedContainer } from 'web/components/sized-container'
+import { useEvent } from 'web/hooks/use-event'
+import { useUser } from 'web/hooks/use-user'
+import { tradingAllowed } from 'common/contract'
+import { Period } from 'web/lib/firebase/users'
+import { periodDurations } from 'web/lib/util/time'
+import { SignedInBinaryMobileBetting } from '../bet/bet-button'
+import { StonkContractChart } from '../charts/contract/stonk'
+import { getDateRange } from '../charts/helpers'
+import { TimeRangePicker } from '../charts/time-range-picker'
+import { Row } from '../layout/row'
+import { AnswersPanel } from '../answers/answers-panel'
+import { Answer, DpmAnswer } from 'common/answer'
+import { UserBetsSummary } from '../bet/bet-summary'
+import { AnswersResolvePanel } from '../answers/answer-resolve-panel'
+import { CancelLabel } from '../outcome-label'
+import { PollPanel } from '../poll/poll-panel'
+import { CreateAnswerPanel } from '../answers/create-answer-panel'
+import clsx from 'clsx'
+import { viewScale } from 'common/chart'
+import { CertOverview } from '../contract/cert-overview'
+import { QfOverview } from '../contract/qf-overview'
+import { PreviewSignedInBinaryMobileBetting } from './preview-bet-button'
+import { PreviewBinaryResolutionOrChance } from './preview-contract-price'
+
+export const PreviewContractOverview = memo(
+ (props: {
+ contract: Contract
+ betPoints: HistoryPoint>[] | MultiPoint[]
+ showResolver: boolean
+ onAnswerCommentClick?: (answer: Answer | DpmAnswer) => void
+ }) => {
+ const { betPoints, contract, showResolver, onAnswerCommentClick } = props
+
+ switch (contract.outcomeType) {
+ case 'BINARY':
+ return (
+
+ )
+ case 'NUMERIC':
+ return
+ case 'PSEUDO_NUMERIC':
+ return (
+
+ )
+ case 'CERT':
+ return
+ case 'QUADRATIC_FUNDING':
+ return
+ case 'FREE_RESPONSE':
+ case 'MULTIPLE_CHOICE':
+ return (
+
+ )
+ case 'STONK':
+ return (
+
+ )
+ case 'BOUNTIED_QUESTION':
+ return <>>
+ case 'POLL':
+ return
+ }
+ }
+)
+
+const NumericOverview = (props: { contract: NumericContract }) => {
+ const { contract } = props
+ return (
+ <>
+
+
+ {(w, h) => (
+
+ )}
+
+ >
+ )
+}
+
+export const PreviewBinaryOverview = (props: {
+ contract: BinaryContract
+ betPoints: HistoryPoint>[]
+}) => {
+ const { contract, betPoints } = props
+ const user = useUser()
+
+ const [showZoomer, setShowZoomer] = useState(false)
+
+ const { viewScale, currentTimePeriod, setTimePeriod, start, maxRange } =
+ useTimePicker(contract)
+
+ return (
+ <>
+
+
+ {
+ setTimePeriod(p)
+ setShowZoomer(true)
+ }}
+ maxRange={maxRange}
+ color="green"
+ />
+
+
+
+
+ {tradingAllowed(contract) && (
+
+ )}
+ >
+ )
+}
+
+export function PreviewBinaryChart(props: {
+ showZoomer: boolean
+ betPoints: HistoryPoint>[]
+ percentBounds?: { max: number; min: number }
+ contract: BinaryContract
+ viewScale: viewScale
+ className?: string
+ controlledStart?: number
+ size?: 'sm' | 'md'
+ color?: string
+}) {
+ const {
+ showZoomer,
+ betPoints,
+ contract,
+ percentBounds,
+ viewScale,
+ className,
+ controlledStart,
+ size = 'md',
+ } = props
+
+ return (
+
+ {(w, h) => (
+
+ )}
+
+ )
+}
+
+const ChoiceOverview = (props: {
+ points: MultiPoint[]
+ contract: MultiContract
+ showResolver: boolean
+ onAnswerCommentClick?: (answer: Answer | DpmAnswer) => void
+}) => {
+ const { points, contract, showResolver, onAnswerCommentClick } = props
+
+ if (!onAnswerCommentClick) return null
+ return (
+ <>
+ {contract.resolution === 'CANCEL' && (
+
+ Resolved
+
+
+ )}
+ {!!points.length && (
+
+ {(w, h) => (
+
+ )}
+
+ )}
+
+ {showResolver ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+ >
+ )
+}
+
+const PseudoNumericOverview = (props: {
+ contract: PseudoNumericContract
+ betPoints: HistoryPoint>[]
+}) => {
+ const { contract, betPoints } = props
+ const { viewScale, currentTimePeriod, setTimePeriod, start, maxRange } =
+ useTimePicker(contract)
+ const user = useUser()
+
+ return (
+ <>
+
+
+
+
+
+ {(w, h) => (
+
+ )}
+
+
+ {user && tradingAllowed(contract) && (
+
+ )}
+ >
+ )
+}
+const StonkOverview = (props: {
+ contract: CPMMStonkContract
+ betPoints: HistoryPoint>[]
+}) => {
+ const { contract, betPoints } = props
+ const { viewScale, currentTimePeriod, setTimePeriod, start, maxRange } =
+ useTimePicker(contract)
+ const user = useUser()
+
+ return (
+ <>
+
+
+
+
+
+ {(w, h) => (
+
+ )}
+
+
+ {user && tradingAllowed(contract) && (
+
+ )}
+ >
+ )
+}
+
+export const useTimePicker = (contract: Contract) => {
+ const viewScale = useViewScale()
+ const [currentTimePeriod, setCurrentTimePeriod] = useState('allTime')
+
+ //zooms out of graph if zoomed in upon time selection change
+ const setTimePeriod = useEvent((timePeriod: Period) => {
+ setCurrentTimePeriod(timePeriod)
+ viewScale.setViewXScale(undefined)
+ viewScale.setViewYScale(undefined)
+ })
+
+ const [startRange, endRange] = getDateRange(contract)
+ const end = endRange ?? Date.now()
+
+ const start =
+ currentTimePeriod === 'allTime'
+ ? undefined
+ : end - periodDurations[currentTimePeriod]
+ const maxRange = end - startRange
+
+ return { viewScale, currentTimePeriod, setTimePeriod, start, maxRange }
+}
diff --git a/web/components/preview/preview-contract-price.tsx b/web/components/preview/preview-contract-price.tsx
new file mode 100644
index 0000000000..4902c18052
--- /dev/null
+++ b/web/components/preview/preview-contract-price.tsx
@@ -0,0 +1,164 @@
+import {
+ BinaryContract,
+ FreeResponseContract,
+ MultipleChoiceContract,
+ NumericContract,
+ PseudoNumericContract,
+ StonkContract,
+} from 'common/contract'
+import { Row } from 'web/components/layout/row'
+import clsx from 'clsx'
+import {
+ BinaryContractOutcomeLabel,
+ CancelLabel,
+ MultiOutcomeLabel,
+ NumericValueLabel,
+} from 'web/components/outcome-label'
+import { getMappedValue } from 'common/pseudo-numeric'
+import { getDisplayProbability, getProbability } from 'common/calculate'
+import { useAnimatedNumber } from 'web/hooks/use-animated-number'
+import { ENV_CONFIG } from 'common/envs/constants'
+import { animated } from '@react-spring/web'
+import { getTextColor } from 'web/components/contract/text-color'
+import { formatLargeNumber, formatPercent } from 'common/util/format'
+import { Tooltip } from 'web/components/widgets/tooltip'
+
+export function PreviewBinaryResolutionOrChance(props: {
+ contract: BinaryContract
+ className?: string
+}) {
+ const { contract, className } = props
+ const { resolution } = contract
+ const textColor = getTextColor(contract)
+
+ const spring = useAnimatedNumber(getDisplayProbability(contract))
+
+ return (
+
+ {resolution ? (
+ <>
+
+ Resolved
+ {resolution === 'MKT' && ' as '}
+
+
+ >
+ ) : (
+ <>
+
+
+ {spring.to((val) => formatPercent(val))}
+
+
+ chance
+ >
+ )}
+
+ )
+}
+
+export function FreeResponseResolution(props: {
+ contract: FreeResponseContract | MultipleChoiceContract
+}) {
+ const { contract } = props
+ const { resolution } = contract
+ if (!(resolution === 'CANCEL' || resolution === 'MKT')) return null
+
+ return (
+
+ Resolved
+
+
+
+ )
+}
+
+export function NumericResolutionOrExpectation(props: {
+ contract: NumericContract
+}) {
+ const { contract } = props
+ const { resolution, resolutionValue = NaN } = contract
+
+ // All distributional numeric questions are resolved now
+ return (
+
+ Resolved
+ {resolution === 'CANCEL' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function PseudoNumericResolutionOrExpectation(props: {
+ contract: PseudoNumericContract
+ className?: string
+}) {
+ const { contract, className } = props
+ const { resolution, resolutionValue, resolutionProbability } = contract
+
+ const value = resolution
+ ? resolutionValue
+ ? resolutionValue
+ : getMappedValue(contract, resolutionProbability ?? 0)
+ : getMappedValue(contract, getProbability(contract))
+ const spring = useAnimatedNumber(value)
+
+ return (
+
+ {resolution ? (
+ <>
+ Resolved
+ {resolution === 'CANCEL' ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+ >
+ ) : (
+ <>
+
+
+ {spring.to((val) => formatLargeNumber(val))}
+
+
+ expected
+ >
+ )}
+
+ )
+}
+
+export function StonkPrice(props: {
+ contract: StonkContract
+ className?: string
+}) {
+ const { contract, className } = props
+
+ const value = getMappedValue(contract, getProbability(contract))
+ const spring = useAnimatedNumber(value)
+ return (
+
+
+ {ENV_CONFIG.moneyMoniker}
+ {spring.to((val) => Math.round(val))}
+
+ per share
+
+ )
+}
diff --git a/web/components/preview/preview-yes-no-selector.tsx b/web/components/preview/preview-yes-no-selector.tsx
new file mode 100644
index 0000000000..a6d3721cef
--- /dev/null
+++ b/web/components/preview/preview-yes-no-selector.tsx
@@ -0,0 +1,198 @@
+import clsx from 'clsx'
+import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid'
+
+import { Row } from '../layout/row'
+import { resolution } from 'common/contract'
+import { Button } from '../buttons/button'
+
+export function PreviewYesNoSelector(props: {
+ selected?: 'YES' | 'NO' | 'LIMIT'
+ onSelect: (selected: 'YES' | 'NO') => void
+ className?: string
+ btnClassName?: string
+ yesLabel?: string
+ noLabel?: string
+ disabled?: boolean
+ highlight?: boolean
+}) {
+ const {
+ selected,
+ onSelect,
+ className,
+ btnClassName,
+ yesLabel,
+ noLabel,
+ disabled,
+ highlight,
+ } = props
+
+ return (
+
+
+
+
+
+ )
+}
+
+export function YesNoCancelSelector(props: {
+ selected: resolution | undefined
+ onSelect: (selected: resolution) => void
+ className?: string
+}) {
+ const { selected, onSelect } = props
+
+ const btnClassName =
+ 'px-0 !py-2 flex-1 first:rounded-l-xl last:rounded-r-xl rounded-r-none rounded-l-none'
+
+ return (
+
+ {/* Should ideally use a radio group instead of buttons */}
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ChooseCancelSelector(props: {
+ selected: 'CHOOSE_ONE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
+ onSelect: (selected: 'CHOOSE_ONE' | 'CHOOSE_MULTIPLE' | 'CANCEL') => void
+}) {
+ const { selected, onSelect } = props
+
+ const btnClassName =
+ 'flex-1 font-medium sm:first:rounded-l-xl sm:last:rounded-r-xl sm:rounded-none whitespace-nowrap'
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export function BuyButton(props: { className?: string; onClick?: () => void }) {
+ const { className, onClick } = props
+ // Note: styles coppied from YesNoSelector
+ return (
+
+ )
+}
+
+export function NumberCancelSelector(props: {
+ selected: 'NUMBER' | 'CANCEL' | undefined
+ onSelect: (selected: 'NUMBER' | 'CANCEL') => void
+ className?: string
+}) {
+ const { selected, onSelect } = props
+
+ const btnClassName = 'flex-1 font-medium whitespace-nowrap'
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/pages/preview/[username]/[contractSlug].tsx b/web/pages/preview/[username]/[contractSlug].tsx
new file mode 100644
index 0000000000..eb9b918897
--- /dev/null
+++ b/web/pages/preview/[username]/[contractSlug].tsx
@@ -0,0 +1,549 @@
+import { UserIcon } from '@heroicons/react/solid'
+import clsx from 'clsx'
+import { first } from 'lodash'
+import Head from 'next/head'
+import Image from 'next/image'
+import Link from 'next/link'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Answer, DpmAnswer } from 'common/answer'
+import { unserializePoints } from 'common/chart'
+import { ContractParams, MaybeAuthedContractParams } from 'common/contract'
+import { ContractMetric } from 'common/contract-metric'
+import { HOUSE_BOT_USERNAME, isTrustworthy } from 'common/envs/constants'
+import { User } from 'common/user'
+import { DeleteMarketButton } from 'web/components/buttons/delete-market-button'
+import { ScrollToTopButton } from 'web/components/buttons/scroll-to-top-button'
+import { BackButton } from 'web/components/contract/back-button'
+import { BountyLeft } from 'web/components/contract/bountied-question'
+import { ChangeBannerButton } from 'web/components/contract/change-banner-button'
+import { ContractDescription } from 'web/components/contract/contract-description'
+import {
+ AuthorInfo,
+ CloseOrResolveTime,
+} from 'web/components/contract/contract-details'
+import { ContractLeaderboard } from 'web/components/contract/contract-leaderboard'
+import { PreviewContractOverview } from 'web/components/preview/preview-contract-overview'
+import { ContractTabs } from 'web/components/contract/contract-tabs'
+import { VisibilityIcon } from 'web/components/contract/contracts-table'
+import { getTopContractMetrics } from 'common/supabase/contract-metrics'
+import ContractSharePanel from 'web/components/contract/contract-share-panel'
+import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
+import { PrivateContractPage } from 'web/components/contract/private-contract'
+import { RelatedContractsList } from 'web/components/contract/related-contracts-widget'
+import { EditableQuestionTitle } from 'web/components/contract/title-edit'
+import { Col } from 'web/components/layout/col'
+import { Page } from 'web/components/layout/page'
+import { Row } from 'web/components/layout/row'
+import { Spacer } from 'web/components/layout/spacer'
+import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel'
+import { ResolutionPanel } from 'web/components/resolution-panel'
+import { ReviewPanel } from 'web/components/reviews/stars'
+import { GradientContainer } from 'web/components/widgets/gradient-container'
+import { Tooltip } from 'web/components/widgets/tooltip'
+import { useAdmin } from 'web/hooks/use-admin'
+import { useAnswersCpmm } from 'web/hooks/use-answers'
+import {
+ useFirebasePublicContract,
+ useIsPrivateContractMember,
+} from 'web/hooks/use-contract-supabase'
+import { useEvent } from 'web/hooks/use-event'
+import { useIsIframe } from 'web/hooks/use-is-iframe'
+import { useRelatedMarkets } from 'web/hooks/use-related-contracts'
+import { useSaveCampaign } from 'web/hooks/use-save-campaign'
+import { useSaveReferral } from 'web/hooks/use-save-referral'
+import { useSaveContractVisitsLocally } from 'web/hooks/use-save-visits'
+import { useSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics'
+import { useTracking } from 'web/hooks/use-tracking'
+import { usePrivateUser, useUser } from 'web/hooks/use-user'
+import { getContractParams } from 'web/lib/firebase/api'
+import { Contract } from 'web/lib/firebase/contracts'
+import { track } from 'web/lib/service/analytics'
+import { db } from 'web/lib/supabase/db'
+import { scrollIntoViewCentered } from 'web/lib/util/scroll'
+import Custom404 from '../../404'
+import ContractEmbedPage from '../../embed/[username]/[contractSlug]'
+import { ExplainerPanel } from 'web/components/explainer-panel'
+import { SidebarSignUpButton } from 'web/components/buttons/sign-up-button'
+import { linkClass } from 'web/components/widgets/site-link'
+import { MarketGroups } from 'web/components/contract/market-groups'
+import { getMultiBetPoints } from 'web/components/charts/contract/choice'
+import { useRealtimeBets } from 'web/hooks/use-bets-supabase'
+import { ContractSEO } from 'web/components/contract/contract-seo'
+import { Linkify } from 'web/components/widgets/linkify'
+
+export async function getStaticProps(ctx: {
+ params: { username: string; contractSlug: string }
+}) {
+ const { contractSlug } = ctx.params
+
+ try {
+ const props = await getContractParams({
+ contractSlug,
+ fromStaticProps: true,
+ })
+ return { props }
+ } catch (e) {
+ if (typeof e === 'object' && e !== null && 'code' in e && e.code === 404) {
+ return {
+ props: { state: 'not found' },
+ revalidate: 60,
+ }
+ }
+ throw e
+ }
+}
+
+export async function getStaticPaths() {
+ return { paths: [], fallback: 'blocking' }
+}
+
+export default function PreviewContractPage(props: MaybeAuthedContractParams) {
+ if (props.state === 'not found') {
+ return
+ }
+
+ return (
+
+ {props.state === 'not authed' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function NonPrivateContractPage(props: {
+ contractParams: ContractParams
+}) {
+ const { contract, historyData, pointsString } = props.contractParams
+
+ const inIframe = useIsIframe()
+ if (!contract) {
+ return
+ } else if (inIframe) {
+ return (
+
+ )
+ } else
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export function PreviewContractPageContent(props: {
+ contractParams: ContractParams & { contract: Contract }
+}) {
+ const { contractParams } = props
+ const {
+ userPositionsByOutcome,
+ comments,
+ totalPositions,
+ creatorTwitter,
+ relatedContracts,
+ } = contractParams
+ const contract =
+ useFirebasePublicContract(
+ contractParams.contract.visibility,
+ contractParams.contract.id
+ ) ?? contractParams.contract
+ if (
+ 'answers' in contractParams.contract &&
+ contract.mechanism === 'cpmm-multi-1'
+ ) {
+ ;(contract as any).answers =
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useAnswersCpmm(contract.id) ?? contractParams.contract.answers
+ }
+ const cachedContract = useMemo(
+ () => contract,
+ [
+ contract.id,
+ contract.resolution,
+ contract.closeTime,
+ 'answers' in contract ? contract.answers : undefined,
+ ]
+ )
+ const user = useUser()
+ const contractMetrics = useSavedContractMetrics(contract)
+ const privateUser = usePrivateUser()
+ const blockedUserIds = privateUser?.blockedUserIds ?? []
+ const [topContractMetrics, setTopContractMetrics] = useState<
+ ContractMetric[]
+ >(contractParams.topContractMetrics)
+
+ useEffect(() => {
+ // If the contract resolves while the user is on the page, get the top contract metrics
+ if (contract.resolution && topContractMetrics.length === 0) {
+ getTopContractMetrics(contract.id, 10, db).then(setTopContractMetrics)
+ }
+ }, [contract.resolution, contract.id, topContractMetrics.length])
+
+ useSaveCampaign()
+ useTracking(
+ 'view market',
+ {
+ slug: contract.slug,
+ contractId: contract.id,
+ creatorId: contract.creatorId,
+ },
+ true
+ )
+ useSaveContractVisitsLocally(user === null, contract.id)
+
+ // Static props load bets in descending order by time
+ const lastBetTime = first(contractParams.historyData.bets)?.createdTime
+
+ const newBets =
+ useRealtimeBets({
+ contractId: contract.id,
+ afterTime: lastBetTime,
+ filterRedemptions: contract.outcomeType !== 'MULTIPLE_CHOICE',
+ order: 'asc',
+ }) ?? []
+ const newBetsWithoutRedemptions = newBets.filter((bet) => !bet.isRedemption)
+ const totalBets = contractParams.totalBets + newBetsWithoutRedemptions.length
+ const bets = useMemo(
+ () => [...contractParams.historyData.bets, ...newBetsWithoutRedemptions],
+ [contractParams.historyData.bets, newBets]
+ )
+
+ const betPoints = useMemo(() => {
+ const points = unserializePoints(contractParams.historyData.points)
+
+ const newPoints =
+ contract.outcomeType === 'MULTIPLE_CHOICE'
+ ? contract.mechanism === 'cpmm-multi-1'
+ ? getMultiBetPoints(contract.answers, newBets)
+ : []
+ : newBets.map((bet) => ({
+ x: bet.createdTime,
+ y: bet.probAfter,
+ obj: { userAvatarUrl: bet.userAvatarUrl },
+ }))
+
+ return [...points, ...newPoints]
+ }, [contractParams.historyData.points, newBets])
+
+ const {
+ isResolved,
+ outcomeType,
+ resolution,
+ closeTime,
+ creatorId,
+ coverImageUrl,
+ uniqueBettorCount,
+ } = contract
+
+ const isAdmin = useAdmin()
+ const isCreator = creatorId === user?.id
+ const isClosed = !!(closeTime && closeTime < Date.now())
+ const trustworthy = isTrustworthy(user?.username)
+
+ // show the resolver by default if the market is closed and you can resolve it
+ const [showResolver, setShowResolver] = useState(false)
+
+ useEffect(() => {
+ // Close resolve panel if you just resolved it.
+ if (isResolved) setShowResolver(false)
+ else if (
+ (isCreator || isAdmin || trustworthy) &&
+ (closeTime ?? 0) < Date.now() &&
+ outcomeType !== 'STONK' &&
+ contract.mechanism !== 'none'
+ ) {
+ setShowResolver(true)
+ }
+ }, [isAdmin, isCreator, trustworthy, closeTime, isResolved])
+
+ useSaveReferral(user, {
+ defaultReferrerUsername: contract.creatorUsername,
+ contractId: contract.id,
+ })
+
+ const [answerResponse, setAnswerResponse] = useState<
+ Answer | DpmAnswer | undefined
+ >(undefined)
+ const tabsContainerRef = useRef(null)
+ const [activeTabIndex, setActiveTabIndex] = useState(0)
+ const onAnswerCommentClick = useEvent((answer: Answer | DpmAnswer) => {
+ setAnswerResponse(answer)
+ if (tabsContainerRef.current) {
+ scrollIntoViewCentered(tabsContainerRef.current)
+ setActiveTabIndex(0)
+ } else {
+ console.error('no ref to scroll to')
+ }
+ })
+ const onCancelAnswerResponse = useEvent(() => setAnswerResponse(undefined))
+
+ const { contracts: relatedMarkets, loadMore } = useRelatedMarkets(
+ contract,
+ relatedContracts
+ )
+
+ // detect whether header is stuck by observing if title is visible
+ const titleRef = useRef(null)
+ const [headerStuck, setStuck] = useState(false)
+ useEffect(() => {
+ const element = titleRef.current
+ if (!element) return
+ const observer = new IntersectionObserver(
+ ([e]) => setStuck(e.intersectionRatio < 1),
+ { threshold: 1 }
+ )
+ observer.observe(element)
+ return () => observer.unobserve(element)
+ }, [titleRef])
+
+ const showExplainerPanel =
+ user === null ||
+ (user && user.createdTime > Date.now() - 24 * 60 * 60 * 1000)
+
+ return (
+ <>
+ {creatorTwitter && (
+
+
+
+ )}
+ {contract.visibility == 'private' && isAdmin && user && (
+
+ )}
+
+
+
+ {/*
+ {coverImageUrl && (
+
+
+
+
+ )}
+
+
+ {(headerStuck || !coverImageUrl) && (
+
+
+
+ )}
+ {headerStuck && (
+
+ {contract.question}
+
+ )}
+
+
+ {(headerStuck || !coverImageUrl) && (
+
+ {!coverImageUrl && isCreator && (
+
+ )}
+
+ )}
+
+
*/}
+
+
+
+ {coverImageUrl && (
+
+
+
+
+
+ {!coverImageUrl && isCreator && (
+
+ )}
+
+
+ )}
+
+
+
+
+ {/* */}
+
+
+
+
+
+ {isCreator &&
+ isResolved &&
+ resolution === 'CANCEL' &&
+ (!uniqueBettorCount || uniqueBettorCount < 2) && (
+
+ )}
+
+ setShowResolver((shown) => !shown)}
+ showEditHistory={true}
+ />
+
+ {showExplainerPanel && (
+
+ )}
+
+ {!user && }
+
+ {!!user && contract.outcomeType !== 'BOUNTIED_QUESTION' && (
+
+ )}
+
+ {isResolved && resolution !== 'CANCEL' && (
+ <>
+ metric.userUsername !== HOUSE_BOT_USERNAME
+ )}
+ contractId={contract.id}
+ currentUser={user}
+ currentUserMetrics={contractMetrics}
+ />
+
+ >
+ )}
+
+
+
+
+ {contract.outcomeType == 'BOUNTIED_QUESTION' && (
+
+ )}
+
+
+
+ {showExplainerPanel && }
+
+
+ track('click related market', { contractId: c.id })
+ }
+ loadMore={loadMore}
+ />
+
+
+
+
+ track('click related market', { contractId: c.id })
+ }
+ loadMore={loadMore}
+ />
+
+
+ >
+ )
+}
+
+function PrivateContractAdminTag(props: { contract: Contract; user: User }) {
+ const { contract, user } = props
+ const isPrivateContractMember = useIsPrivateContractMember(
+ user.id,
+ contract.id
+ )
+ if (isPrivateContractMember) return <>>
+ return (
+
+
+ ADMIN
+
+
+ )
+}