Merge branch 'main' into embed-sizing

This commit is contained in:
Sinclair Chen 2022-10-02 11:45:05 -07:00
commit 241fc7a802
34 changed files with 644 additions and 516 deletions

View File

@ -5,4 +5,4 @@ export type Like = {
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 5 export const LIKE_TIP_AMOUNT = 10

View File

@ -5,7 +5,6 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Row } from './layout/row' import { Row } from './layout/row'
export function AmountInput(props: { export function AmountInput(props: {
@ -36,9 +35,6 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return ( return (
<> <>
<Col className={className}> <Col className={className}>
@ -50,7 +46,7 @@ export function AmountInput(props: {
className={clsx( className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error', error && 'input-error',
isMobile ? 'w-24' : '', 'w-24 md:w-auto',
inputClassName inputClassName
)} )}
ref={inputRef} ref={inputRef}
@ -59,7 +55,6 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}

View File

@ -47,7 +47,6 @@ import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { useWindowSize } from 'web/hooks/use-window-size'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -179,12 +178,7 @@ export function BuyPanel(props: {
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const windowSize = useWindowSize() const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const initialOutcome =
windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(
initialOutcome
)
const [betAmount, setBetAmount] = useState<number | undefined>(10) const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)

View File

@ -46,7 +46,6 @@ export function Button(props: {
<button <button
type={type} type={type}
className={clsx( className={clsx(
className,
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed', 'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses, sizeClasses,
color === 'green' && color === 'green' &&
@ -66,7 +65,8 @@ export function Button(props: {
color === 'gray-white' && color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50', 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' && color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none' 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)} )}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sortBy } from 'lodash' import { last, sortBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
@ -6,7 +6,6 @@ import { Bet } from 'common/bet'
import { getProbability, getInitialProbability } from 'common/calculate' import { getProbability, getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract' import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { import {
TooltipProps, TooltipProps,
MARGIN_X, MARGIN_X,
@ -17,7 +16,6 @@ import {
formatPct, formatPct,
} from '../helpers' } from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -25,19 +23,19 @@ const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: b.probAfter, y: b.probAfter,
datum: b, obj: b,
})) }))
} }
const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(x, start, end)}</span> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatPct(y)}</span> <span className="text-greyscale-6">{formatPct(data.y)}</span>
</Row> </Row>
) )
} }
@ -45,10 +43,11 @@ const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => {
export const BinaryContractChart = (props: { export const BinaryContractChart = (props: {
contract: BinaryContract contract: BinaryContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets, onMouseOver } = props const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const startP = getInitialProbability(contract) const startP = getInitialProbability(contract)
const endP = getProbability(contract) const endP = getProbability(contract)
@ -67,28 +66,19 @@ export const BinaryContractChart = (props: {
Date.now() Date.now()
) )
const visibleRange = [start, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <SingleValueHistoryChart
{width > 0 && ( w={width}
<SingleValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} color="#11b981"
data={data} onMouseOver={onMouseOver}
color="#11b981" Tooltip={BinaryChartTooltip}
onMouseOver={onMouseOver} pct
Tooltip={BinaryChartTooltip} />
pct
/>
)}
</div>
) )
} }

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash' import { last, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
@ -6,7 +6,6 @@ import { Bet } from 'common/bet'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { import {
TooltipProps, TooltipProps,
@ -18,7 +17,6 @@ import {
formatDateInRange, formatDateInRange,
} from '../helpers' } from '../helpers'
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -114,7 +112,7 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
points.push({ points.push({
x: new Date(bet.createdTime), x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
datum: bet, obj: bet,
}) })
} }
return points return points
@ -146,10 +144,11 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: MultiPoint<Bet> | undefined) => void onMouseOver?: (p: MultiPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets, onMouseOver } = props const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const answers = useMemo( const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length), () => getTrackedAnswers(contract, CATEGORY_COLORS.length),
@ -173,20 +172,16 @@ export const ChoiceContractChart = (props: {
Date.now() Date.now()
) )
const visibleRange = [start, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 250 : 350)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
const ChoiceTooltip = useMemo( const ChoiceTooltip = useMemo(
() => (props: TooltipProps<MultiPoint<Bet>>) => { () => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const legendItems = sortBy( const legendItems = sortBy(
y.map((p, i) => ({ data.y.map((p, i) => ({
color: CATEGORY_COLORS[i], color: CATEGORY_COLORS[i],
label: answers[i].text, label: answers[i].text,
value: formatPct(p), value: formatPct(p),
@ -197,9 +192,11 @@ export const ChoiceContractChart = (props: {
return ( return (
<> <>
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} {data.obj && (
<Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
)}
<span className="text-semibold text-base"> <span className="text-semibold text-base">
{formatDateInRange(x, start, end)} {formatDateInRange(d, start, end)}
</span> </span>
</Row> </Row>
<Legend className="max-w-xs" items={legendItems} /> <Legend className="max-w-xs" items={legendItems} />
@ -210,20 +207,16 @@ export const ChoiceContractChart = (props: {
) )
return ( return (
<div ref={containerRef}> <MultiValueHistoryChart
{width > 0 && ( w={width}
<MultiValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} colors={CATEGORY_COLORS}
data={data} onMouseOver={onMouseOver}
colors={CATEGORY_COLORS} Tooltip={ChoiceTooltip}
onMouseOver={onMouseOver} pct
Tooltip={ChoiceTooltip} />
pct
/>
)}
</div>
) )
} }

View File

@ -8,7 +8,8 @@ import { NumericContractChart } from './numeric'
export const ContractChart = (props: { export const ContractChart = (props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
}) => { }) => {
const { contract } = props const { contract } = props
switch (contract.outcomeType) { switch (contract.outcomeType) {

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { range } from 'lodash' import { range } from 'lodash'
import { scaleLinear } from 'd3-scale' import { scaleLinear } from 'd3-scale'
@ -6,10 +6,8 @@ import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers' import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts' import { DistributionPoint, DistributionChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
const getNumericChartData = (contract: NumericContract) => { const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract const { totalShares, bucketCount, min, max } = contract
@ -21,45 +19,41 @@ const getNumericChartData = (contract: NumericContract) => {
})) }))
} }
const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { const NumericChartTooltip = (
const { x, y } = props.p props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return ( return (
<> <>
<span className="text-semibold">{formatLargeNumber(x)}</span> <span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-greyscale-6">{formatPct(y, 2)}</span> <span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</> </>
) )
} }
export const NumericContractChart = (props: { export const NumericContractChart = (props: {
contract: NumericContract contract: NumericContract
height?: number width: number
height: number
onMouseOver?: (p: DistributionPoint | undefined) => void onMouseOver?: (p: DistributionPoint | undefined) => void
}) => { }) => {
const { contract, onMouseOver } = props const { contract, width, height, onMouseOver } = props
const { min, max } = contract const { min, max } = contract
const data = useMemo(() => getNumericChartData(contract), [contract]) const data = useMemo(() => getNumericChartData(contract), [contract])
const isMobile = useIsMobile(800)
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const maxY = Math.max(...data.map((d) => d.y)) const maxY = Math.max(...data.map((d) => d.y))
const xScale = scaleLinear([min, max], [0, width - MARGIN_X]) const xScale = scaleLinear([min, max], [0, width - MARGIN_X])
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <DistributionChart
{width > 0 && ( w={width}
<DistributionChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} color={NUMERIC_GRAPH_COLOR}
data={data} onMouseOver={onMouseOver}
color={NUMERIC_GRAPH_COLOR} Tooltip={NumericChartTooltip}
onMouseOver={onMouseOver} />
Tooltip={NumericChartTooltip}
/>
)}
</div>
) )
} }

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { last, sortBy } from 'lodash' import { last, sortBy } from 'lodash'
import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
@ -8,7 +8,6 @@ import { getInitialProbability, getProbability } from 'common/calculate'
import { formatLargeNumber } from 'common/util/format' import { formatLargeNumber } from 'common/util/format'
import { PseudoNumericContract } from 'common/contract' import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { import {
TooltipProps, TooltipProps,
MARGIN_X, MARGIN_X,
@ -18,7 +17,6 @@ import {
formatDateInRange, formatDateInRange,
} from '../helpers' } from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -37,19 +35,21 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: scaleP(b.probAfter), y: scaleP(b.probAfter),
datum: b, obj: b,
})) }))
} }
const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const PseudoNumericChartTooltip = (
const { p, xScale } = props props: TooltipProps<Date, HistoryPoint<Bet>>
const { x, y, datum } = p ) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(x, start, end)}</span> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(y)}</span> <span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row> </Row>
) )
} }
@ -57,10 +57,11 @@ const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => {
export const PseudoNumericContractChart = (props: { export const PseudoNumericContractChart = (props: {
contract: PseudoNumericContract contract: PseudoNumericContract
bets: Bet[] bets: Bet[]
height?: number width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => { }) => {
const { contract, bets, onMouseOver } = props const { contract, bets, width, height, onMouseOver } = props
const { min, max, isLogScale } = contract const { min, max, isLogScale } = contract
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const scaleP = useMemo( const scaleP = useMemo(
@ -84,30 +85,21 @@ export const PseudoNumericContractChart = (props: {
Date.now() Date.now()
) )
const visibleRange = [start, rightmostDate] const visibleRange = [start, rightmostDate]
const isMobile = useIsMobile(800) const xScale = scaleTime(visibleRange, [0, width ?? 0 - MARGIN_X])
const containerRef = useRef<HTMLDivElement>(null)
const width = useElementWidth(containerRef) ?? 0
const height = props.height ?? (isMobile ? 150 : 250)
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
// clamp log scale to make sure zeroes go to the bottom // clamp log scale to make sure zeroes go to the bottom
const yScale = isLogScale const yScale = isLogScale
? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) ? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true)
: scaleLinear([min, max], [height - MARGIN_Y, 0]) : scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0])
return ( return (
<div ref={containerRef}> <SingleValueHistoryChart
{width > 0 && ( w={width}
<SingleValueHistoryChart h={height}
w={width} xScale={xScale}
h={height} yScale={yScale}
xScale={xScale} data={data}
yScale={yScale} onMouseOver={onMouseOver}
data={data} Tooltip={PseudoNumericChartTooltip}
onMouseOver={onMouseOver} color={NUMERIC_GRAPH_COLOR}
Tooltip={PseudoNumericChartTooltip} />
color={NUMERIC_GRAPH_COLOR}
/>
)}
</div>
) )
} }

View File

@ -13,6 +13,7 @@ import {
import { range } from 'lodash' import { range } from 'lodash'
import { import {
ContinuousScale,
SVGChart, SVGChart,
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
@ -31,6 +32,19 @@ const getTickValues = (min: number, max: number, n: number) => {
return [min, ...range(1, n - 1).map((i) => min + step * i), max] return [min, ...range(1, n - 1).map((i) => min + step * i), max]
} }
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
}
}
export const DistributionChart = <P extends DistributionPoint>(props: { export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[] data: P[]
w: number w: number
@ -39,7 +53,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
xScale: ScaleContinuousNumeric<number, number> xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<number, P>
}) => { }) => {
const { color, data, yScale, w, h, Tooltip } = props const { color, data, yScale, w, h, Tooltip } = props
@ -50,7 +64,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100) const xAxis = axisBottom<number>(xScale).ticks(w / 100)
@ -58,6 +71,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, xScale, yScale]) }, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -69,14 +84,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -107,7 +114,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { colors, data, yScale, w, h, Tooltip, pct } = props const { colors, data, yScale, w, h, Tooltip, pct } = props
@ -119,7 +126,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -141,6 +147,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data) return d3Stack(data)
}, [data]) }, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -152,14 +160,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -193,7 +193,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, yScale, w, h, Tooltip, pct } = props const { color, data, yScale, w, h, Tooltip, pct } = props
@ -204,7 +204,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -218,6 +217,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale]) }, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -229,14 +230,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}

View File

@ -16,8 +16,14 @@ import dayjs from 'dayjs'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
export interface ContinuousScale<T> extends AxisScale<T> {
invert(n: number): T
}
export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T }
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
@ -118,18 +124,19 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
const [mouseState, setMouseState] = useState<{ pos: TooltipPosition; p: P }>() const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y const innerH = h - MARGIN_Y
@ -148,7 +155,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouseState(undefined) setMouse(undefined)
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -168,26 +175,40 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev) const [x, y] = pointer(ev)
const p = onMouseOver(mouseX, mouseY) const data = onMouseOver(x, y)
if (p != null) { if (data !== undefined) {
const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH) setMouse({ x, y, data })
setMouseState({ pos, p })
} else { } else {
setMouseState(undefined) setMouse(undefined)
} }
} }
} }
const onPointerLeave = () => { const onPointerLeave = () => {
setMouseState(undefined) setMouse(undefined)
} }
return ( return (
<div className="relative"> <div className="relative overflow-hidden">
{mouseState && Tooltip && ( {mouse && Tooltip && (
<TooltipContainer pos={mouseState.pos}> <TooltipContainer
<Tooltip xScale={xAxis.scale()} p={mouseState.p} /> setElem={tooltipMeasure.setElem}
pos={getTooltipPosition(
mouse.x,
mouse.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer> </TooltipContainer>
)} )}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
@ -216,43 +237,51 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
) )
} }
export type TooltipPosition = { export type TooltipPosition = { left: number; bottom: number }
top?: number
right?: number
bottom?: number
left?: number
}
export const getTooltipPosition = ( export const getTooltipPosition = (
mouseX: number, mouseX: number,
mouseY: number, mouseY: number,
w: number, containerWidth: number,
h: number containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
) => { ) => {
const result: TooltipPosition = {} let left = mouseX + 12
if (mouseX <= (3 * w) / 4) { let bottom = containerHeight - mouseY + 12
result.left = mouseX + 10 // in the left three quarters if (tooltipWidth != null) {
} else { const overflow = left + tooltipWidth - containerWidth
result.right = w - mouseX + 10 // in the right quarter if (overflow > 0) {
left -= overflow
}
} }
if (mouseY <= h / 4) { if (tooltipHeight != null) {
result.top = mouseY + 10 // in the top quarter const overflow = tooltipHeight - mouseY
} else { if (overflow > 0) {
result.bottom = h - mouseY + 10 // in the bottom three quarters bottom -= overflow
}
} }
return result return { left, bottom }
} }
export type TooltipProps<P> = { p: P; xScale: XScale<P> } export type TooltipProps<X, T> = {
export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>> mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: { export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition pos: TooltipPosition
className?: string className?: string
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { pos, className, children } = props const { setElem, pos, className, children } = props
return ( return (
<div <div
ref={setElem}
className={clsx( className={clsx(
className, className,
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' 'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'

View File

@ -22,7 +22,10 @@ export function BountiedContractSmallBadge(props: {
return ( return (
<Tooltip <Tooltip
text={CommentBountiesTooltipText(openCommentBounties)} text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom" placement="bottom"
> >
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> <span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
@ -33,8 +36,11 @@ export function BountiedContractSmallBadge(props: {
) )
} }
export const CommentBountiesTooltipText = (openCommentBounties: number) => export const CommentBountiesTooltipText = (
`The creator of this market may award ${formatMoney( creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney( )} for good comments. ${formatMoney(
openCommentBounties openCommentBounties

View File

@ -183,6 +183,7 @@ export function MarketSubheader(props: {
contract={contract} contract={contract}
resolvedDate={resolvedDate} resolvedDate={resolvedDate}
isCreator={isCreator} isCreator={isCreator}
disabled={disabled}
/> />
{!isMobile && ( {!isMobile && (
<Row className={'gap-1'}> <Row className={'gap-1'}>
@ -200,8 +201,9 @@ export function CloseOrResolveTime(props: {
contract: Contract contract: Contract
resolvedDate: any resolvedDate: any
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { contract, resolvedDate, isCreator } = props const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) { if (!!closeTime || !!resolvedDate) {
return ( return (
@ -225,6 +227,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
isCreator={isCreator ?? false} isCreator={isCreator ?? false}
disabled={disabled}
/> />
</Row> </Row>
)} )}
@ -245,7 +248,8 @@ export function MarketGroups(props: {
return ( return (
<> <>
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} /> <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && ( {!disabled && user && (
<button <button
className="text-greyscale-4 hover:text-greyscale-3" className="text-greyscale-4 hover:text-greyscale-3"
@ -330,14 +334,29 @@ export function ExtraMobileContractDetails(props: {
) )
} }
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { export function GroupDisplay(props: {
const { groupToDisplay } = props groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
if (groupToDisplay) { if (groupToDisplay) {
return ( const groupSection = (
<a
className={clsx(
'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
!disabled && 'hover:bg-greyscale-3 cursor-pointer'
)}
>
{groupToDisplay.name}
</a>
)
return disabled ? (
groupSection
) : (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}> <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]"> {groupSection}
{groupToDisplay.name}
</a>
</Link> </Link>
) )
} else } else
@ -352,8 +371,9 @@ function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { closeTime, contract, isCreator } = props const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime) const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs() const dayJsNow = dayjs()
@ -452,8 +472,8 @@ function EditableCloseDate(props: {
time={closeTime} time={closeTime}
> >
<span <span
className={isCreator ? 'cursor-pointer' : ''} className={!disabled && isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)} onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
> >
{isSameDay ? ( {isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span> <span className={'capitalize'}> {fromNow(closeTime)}</span>

View File

@ -1,13 +1,8 @@
import React from 'react' import React, { useEffect, useRef, useState } from 'react'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import { ContractChart } from 'web/components/charts/contract'
BinaryContractChart,
NumericContractChart,
PseudoNumericContractChart,
ChoiceContractChart,
} from 'web/components/charts/contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
@ -48,8 +43,43 @@ const BetWidget = (props: { contract: CPMMContract }) => {
) )
} }
const NumericOverview = (props: { contract: NumericContract }) => { const SizedContractChart = (props: {
const { contract } = props contract: Contract
bets: Bet[]
fullHeight: number
mobileHeight: number
}) => {
const { contract, bets, fullHeight, mobileHeight } = props
const containerRef = useRef<HTMLDivElement>(null)
const [chartWidth, setChartWidth] = useState<number>()
const [chartHeight, setChartHeight] = useState<number>()
useEffect(() => {
const handleResize = () => {
setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight)
setChartWidth(containerRef.current?.clientWidth)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [fullHeight, mobileHeight])
return (
<div ref={containerRef}>
{chartWidth != null && chartHeight != null && (
<ContractChart
contract={contract}
bets={bets}
width={chartWidth}
height={chartHeight}
/>
)}
</div>
)
}
const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => {
const { contract, bets } = props
return ( return (
<Col className="gap-1 md:gap-2"> <Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4"> <Col className="gap-3 px-2 sm:gap-4">
@ -66,7 +96,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract} contract={contract}
/> />
</Col> </Col>
<NumericContractChart contract={contract} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -82,7 +117,12 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<BinaryResolutionOrChance contract={contract} large /> <BinaryResolutionOrChance contract={contract} large />
</Row> </Row>
</Col> </Col>
<BinaryContractChart contract={contract} bets={bets} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} /> <BinaryMobileBetting contract={contract} />
@ -107,9 +147,12 @@ const ChoiceOverview = (props: {
<FreeResponseResolutionOrChance contract={contract} truncate="none" /> <FreeResponseResolutionOrChance contract={contract} truncate="none" />
)} )}
</Col> </Col>
<Col className={'mb-1 gap-y-2'}> <SizedContractChart
<ChoiceContractChart contract={contract} bets={bets} /> contract={contract}
</Col> bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col> </Col>
) )
} }
@ -135,7 +178,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />} {tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row> </Row>
</Col> </Col>
<PseudoNumericContractChart contract={contract} bets={bets} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -149,7 +197,7 @@ export const ContractOverview = (props: {
case 'BINARY': case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} /> return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC': case 'NUMERIC':
return <NumericOverview contract={contract} /> return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC': case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} /> return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE': case 'FREE_RESPONSE':

View File

@ -75,7 +75,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = useState<'Newest' | 'Best'>('Best') const [sort, setSort] = useState<'Newest' | 'Best'>('Newest')
const me = useUser() const me = useUser()
if (comments == null) { if (comments == null) {
return <LoadingIndicator /> return <LoadingIndicator />
@ -159,7 +159,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<Tooltip <Tooltip
text={ text={
sort === 'Best' sort === 'Best'
? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' ? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: '' : ''
} }
> >

View File

@ -18,9 +18,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
return ( return (
<Row> <Row>
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && ( <LikeMarketButton contract={contract} user={user} />
<LikeMarketButton contract={contract} user={user} />
)}
<Tooltip text="Share" placement="bottom" noTap noFade> <Tooltip text="Share" placement="bottom" noTap noFade>
<Button <Button
size="sm" size="sm"

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline' import React, { useMemo, useState } from 'react'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes' import { useUserLikes } from 'web/hooks/use-likes'
@ -8,74 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes' import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like' import { LIKE_TIP_AMOUNT } from 'common/like'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash' import { sum } from 'lodash'
import { Tooltip } from '../tooltip' import { TipButton } from './tip-button'
export function LikeMarketButton(props: { export function LikeMarketButton(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
}) { }) {
const { contract, user } = props const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id const tips = useMarketTipTxns(contract.id)
)
const totalTipped = useMemo(() => { const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount)) return sum(tips.map((tip) => tip.amount))
}, [tips]) }, [tips])
const likes = useUserLikes(user?.id) const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes const userLikedContractIds = likes
?.filter((l) => l.type === 'contract') ?.filter((l) => l.type === 'contract')
.map((l) => l.id) .map((l) => l.id)
const onLike = async () => { const onLike = async () => {
if (!user) return firebaseLogin() if (!user) return firebaseLogin()
await likeContract(user, contract)
setIsLiking(true)
likeContract(user, contract).catch(() => setIsLiking(false))
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
return ( return (
<Tooltip <TipButton
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`} onClick={onLike}
placement="bottom" tipAmount={LIKE_TIP_AMOUNT}
noTap totalTipped={totalTipped}
noFade userTipped={
> !!user &&
<Button (isLiking ||
size={'sm'} userLikedContractIds?.includes(contract.id) ||
className={'max-w-xs self-center'} (!likes && !!contract.likedByUserIds?.includes(user.id)))
color={'gray-white'} }
onClick={onLike} disabled={contract.creatorId === user?.id}
> />
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
) )
} }

View File

@ -0,0 +1,61 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney } from 'common/util/format'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
export function TipButton(props: {
tipAmount: number
totalTipped: number
onClick: () => void
userTipped: boolean
isCompact?: boolean
disabled?: boolean
}) {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
props
return (
<Tooltip
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={clsx(
'max-w-xs self-center',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick}
disabled={disabled}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
)
}

View File

@ -177,10 +177,6 @@ export function FeedComment(props: {
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Row className="mt-2 items-center gap-6 text-xs text-gray-500">
{tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
@ -189,6 +185,10 @@ export function FeedComment(props: {
Reply Reply
</button> </button>
)} )}
{tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row> </Row>
</div> </div>
</Row> </Row>

View File

@ -32,27 +32,27 @@ export function GroupSelector(props: {
const openGroups = useOpenGroups() const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id) const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? [] const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
.sort((a, b) => b.totalContracts - a.totalContracts)
// put the groups the user is a member of first
.sort((a, b) => {
if (memberGroupIds.includes(a.id)) {
return -1
}
if (memberGroupIds.includes(b.id)) {
return 1
}
return 0
})
const filteredGroups = availableGroups.filter((group) => const sortGroups = (groups: Group[]) =>
searchInAny(query, group.name) groups.sort(
(a, b) =>
// weight group higher if user is a member
(memberGroupIds.includes(b.id) ? 5 : 1) * b.totalContracts -
(memberGroupIds.includes(a.id) ? 5 : 1) * a.totalContracts
)
const availableGroups = sortGroups(
openGroups
.concat(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
)
const filteredGroups = sortGroups(
availableGroups.filter((group) => searchInAny(query, group.name))
) )
if (!showSelector || !creator) { if (!showSelector || !creator) {

View File

@ -164,7 +164,6 @@ function getMoreDesktopNavigation(user?: User | null) {
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Dating docs', href: '/date-docs' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' }, { name: 'Help & About', href: 'https://help.manifold.markets/' },
{ {
name: 'Sign out', name: 'Sign out',
@ -227,7 +226,6 @@ function getMoreMobileNav() {
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Dating docs', href: '/date-docs' },
], ],
signOut signOut
) )

View File

@ -1,22 +1,17 @@
import { import { useEffect, useRef, useState } from 'react'
ChevronDoubleRightIcon, import toast from 'react-hot-toast'
ChevronLeftIcon, import { debounce, sum } from 'lodash'
ChevronRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { formatMoney } from 'common/util/format'
import { debounce, sum } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { CommentTips } from 'web/hooks/use-tip-txns' import { CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/api' import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { TipButton } from './contract/tip-button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Tooltip } from './tooltip' import { LIKE_TIP_AMOUNT } from 'common/like'
import { formatMoney } from 'common/util/format'
const TIP_SIZE = 10
export function Tipper(prop: { comment: Comment; tips: CommentTips }) { export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop const { comment, tips } = prop
@ -26,6 +21,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const savedTip = tips[myId] ?? 0 const savedTip = tips[myId] ?? 0
const [localTip, setLocalTip] = useState(savedTip) const [localTip, setLocalTip] = useState(savedTip)
// listen for user being set // listen for user being set
const initialized = useRef(false) const initialized = useRef(false)
useEffect(() => { useEffect(() => {
@ -78,71 +74,22 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const addTip = (delta: number) => { const addTip = (delta: number) => {
setLocalTip(localTip + delta) setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta) me && saveTip(me, comment, localTip - savedTip + delta)
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
const canDown = me && localTip > savedTip const canUp =
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5 me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT
return ( return (
<Row className="items-center gap-0.5"> <Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} /> <TipButton
<span className="font-bold">{Math.floor(total)}</span> tipAmount={LIKE_TIP_AMOUNT}
<UpTip totalTipped={total}
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined} onClick={() => addTip(+LIKE_TIP_AMOUNT)}
value={localTip} userTipped={localTip > 0}
disabled={!canUp}
isCompact
/> />
{localTip === 0 ? (
''
) : (
<span
className={clsx(
'ml-1 font-semibold',
localTip > 0 ? 'text-primary' : 'text-red-400'
)}
>
({formatMoney(localTip)} tip)
</span>
)}
</Row> </Row>
) )
} }
function DownTip(props: { onClick?: () => void }) {
const { onClick } = props
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `-${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-red-600 disabled:text-gray-100"
disabled={!onClick}
onClick={onClick}
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
</Tooltip>
)
}
function UpTip(props: { onClick?: () => void; value: number }) {
const { onClick, value } = props
const IconKind = value > TIP_SIZE ? ChevronDoubleRightIcon : ChevronRightIcon
return (
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
noTap
>
<button
className="hover:text-primary disabled:text-gray-100"
disabled={!onClick}
onClick={onClick}
>
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
</button>
</Tooltip>
)
}

View File

@ -8,12 +8,14 @@ import {
getUserBetContracts, getUserBetContracts,
getUserBetContractsQuery, getUserBetContractsQuery,
listAllContracts, listAllContracts,
trendingContractsQuery,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { QueryClient, useQuery, useQueryClient } from 'react-query' import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS, sleep } from 'common/util/time' import { MINUTE_MS, sleep } from 'common/util/time'
import { query, limit } from 'firebase/firestore' import {
import { dailyScoreIndex } from 'web/lib/service/algolia' dailyScoreIndex,
newIndex,
trendingIndex,
} from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash' import { zipObject } from 'lodash'
@ -27,16 +29,50 @@ export const useContracts = () => {
return contracts return contracts
} }
export const useTrendingContracts = (maxContracts: number) => {
const { data } = useQuery(['trending-contracts', maxContracts], () =>
trendingIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false'],
hitsPerPage: maxContracts,
})
)
if (!data) return undefined
return data.hits
}
export const useNewContracts = (maxContracts: number) => {
const { data } = useQuery(['newest-contracts', maxContracts], () =>
newIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false'],
hitsPerPage: maxContracts,
})
)
if (!data) return undefined
return data.hits
}
export const useContractsByDailyScoreNotBetOn = (
userId: string | null | undefined,
maxContracts: number
) => {
const { data } = useQuery(['daily-score', userId, maxContracts], () =>
dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: ['isResolved:false', `uniqueBettors:-${userId}`],
hitsPerPage: maxContracts,
})
)
if (!userId || !data) return undefined
return data.hits.filter((c) => c.dailyScore)
}
export const useContractsByDailyScoreGroups = ( export const useContractsByDailyScoreGroups = (
groupSlugs: string[] | undefined groupSlugs: string[] | undefined
) => { ) => {
const facetFilters = ['isResolved:false']
const { data } = useQuery(['daily-score', groupSlugs], () => const { data } = useQuery(['daily-score', groupSlugs], () =>
Promise.all( Promise.all(
(groupSlugs ?? []).map((slug) => (groupSlugs ?? []).map((slug) =>
dailyScoreIndex.search<CPMMBinaryContract>('', { dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: [...facetFilters, `groupLinks.slug:${slug}`], facetFilters: ['isResolved:false', `groupLinks.slug:${slug}`],
}) })
) )
) )
@ -56,14 +92,6 @@ export const getCachedContracts = async () =>
staleTime: Infinity, staleTime: Infinity,
}) })
export const useTrendingContracts = (maxContracts: number) => {
const result = useFirestoreQueryData(
['trending-contracts', maxContracts],
query(trendingContractsQuery, limit(maxContracts))
)
return result.data
}
export const useInactiveContracts = () => { export const useInactiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>() const [contracts, setContracts] = useState<Contract[] | undefined>()

View File

@ -1,17 +0,0 @@
import { RefObject, useState, useEffect } from 'react'
// todo: consider consolidation with use-measure-size
export const useElementWidth = <T extends Element>(ref: RefObject<T>) => {
const [width, setWidth] = useState<number>()
useEffect(() => {
const handleResize = () => {
setWidth(ref.current?.clientWidth)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [ref])
return width
}

View File

@ -1,5 +1,5 @@
import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react' import { useEffect } from 'react'
import { track } from 'web/lib/service/analytics'
import { inIframe } from './use-is-iframe' import { inIframe } from './use-is-iframe'
export const useTracking = ( export const useTracking = (
@ -10,5 +10,5 @@ export const useTracking = (
useEffect(() => { useEffect(() => {
if (excludeIframe && inIframe()) return if (excludeIframe && inIframe()) return
track(eventName, eventProperties) track(eventName, eventProperties)
}, []) }, [eventName, eventProperties, excludeIframe])
} }

View File

@ -14,6 +14,8 @@ export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}` return `${indexPrefix}contracts-${sort}`
} }
export const trendingIndex = searchClient.initIndex(getIndexName('score'))
export const newIndex = searchClient.initIndex(getIndexName('newest'))
export const probChangeDescendingIndex = searchClient.initIndex( export const probChangeDescendingIndex = searchClient.initIndex(
getIndexName('prob-change-day') getIndexName('prob-change-day')
) )

View File

@ -4,6 +4,7 @@ import { listAllComments } from 'web/lib/firebase/comments'
import { getContractFromId } from 'web/lib/firebase/contracts' import { getContractFromId } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { FullMarket, ApiError, toFullMarket } from '../../_types' import { FullMarket, ApiError, toFullMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -24,6 +25,6 @@ export default async function handler(
return return
} }
res.setHeader('Cache-Control', 'max-age=0') res.setHeader('Cache-Control', marketCacheStrategy)
return res.status(200).json(toFullMarket(contract, comments, bets)) return res.status(200).json(toFullMarket(contract, comments, bets))
} }

View File

@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { getContractFromId } from 'web/lib/firebase/contracts' import { getContractFromId } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { ApiError, toLiteMarket, LiteMarket } from '../../_types' import { ApiError, toLiteMarket, LiteMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -18,6 +19,6 @@ export default async function handler(
return return
} }
res.setHeader('Cache-Control', 'max-age=0') res.setHeader('Cache-Control', marketCacheStrategy)
return res.status(200).json(toLiteMarket(contract)) return res.status(200).json(toLiteMarket(contract))
} }

View File

@ -6,6 +6,8 @@ import { toLiteMarket, ValidationError } from './_types'
import { z } from 'zod' import { z } from 'zod'
import { validate } from './_validate' import { validate } from './_validate'
export const marketCacheStrategy = 's-maxage=15, stale-while-revalidate=45'
const queryParams = z const queryParams = z
.object({ .object({
limit: z limit: z
@ -39,7 +41,7 @@ export default async function handler(
try { try {
const contracts = await listAllContracts(limit, before) const contracts = await listAllContracts(limit, before)
// Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') res.setHeader('Cache-Control', marketCacheStrategy)
res.status(200).json(contracts.map(toLiteMarket)) res.status(200).json(contracts.map(toLiteMarket))
} catch (e) { } catch (e) {
res.status(400).json({ res.status(400).json({

View File

@ -280,25 +280,27 @@ export function NewContract(props: {
<label className="label"> <label className="label">
<span className="mb-1">Answer type</span> <span className="mb-1">Answer type</span>
</label> </label>
<ChoicesToggleGroup <Row>
currentChoice={outcomeType} <ChoicesToggleGroup
setChoice={(choice) => { currentChoice={outcomeType}
if (choice === 'FREE_RESPONSE') setChoice={(choice) => {
setMarketInfoText( if (choice === 'FREE_RESPONSE')
'Users can submit their own answers to this market.' setMarketInfoText(
) 'Users can submit their own answers to this market.'
else setMarketInfoText('') )
setOutcomeType(choice as outcomeType) else setMarketInfoText('')
}} setOutcomeType(choice as outcomeType)
choicesMap={{ }}
'Yes / No': 'BINARY', choicesMap={{
// 'Multiple choice': 'MULTIPLE_CHOICE', 'Yes / No': 'BINARY',
'Free response': 'FREE_RESPONSE', // 'Multiple choice': 'MULTIPLE_CHOICE',
// Numeric: 'PSEUDO_NUMERIC', 'Free response': 'FREE_RESPONSE',
}} // Numeric: 'PSEUDO_NUMERIC',
isSubmitting={isSubmitting} }}
className={'col-span-4'} isSubmitting={isSubmitting}
/> className={'col-span-4'}
/>
</Row>
{marketInfoText && ( {marketInfoText && (
<div className="mt-3 ml-1 text-sm text-indigo-700"> <div className="mt-3 ml-1 text-sm text-indigo-700">
{marketInfoText} {marketInfoText}
@ -390,23 +392,7 @@ export function NewContract(props: {
</> </>
)} )}
<div className="form-control mb-1 items-start gap-1"> <Spacer h={4} />
<label className="label gap-2">
<span className="mb-1">Visibility</span>
<InfoTooltip text="Whether the market will be listed on the home page." />
</label>
<ChoicesToggleGroup
currentChoice={visibility}
setChoice={(choice) => setVisibility(choice as visibility)}
choicesMap={{
Public: 'public',
Unlisted: 'unlisted',
}}
isSubmitting={isSubmitting}
/>
</div>
<Spacer h={6} />
<Row className={'items-end gap-x-2'}> <Row className={'items-end gap-x-2'}>
<GroupSelector <GroupSelector
@ -421,6 +407,20 @@ export function NewContract(props: {
</SiteLink> </SiteLink>
)} )}
</Row> </Row>
<Row className="form-control my-2 items-center gap-2 text-sm">
<span>Display this market on homepage</span>
<input
type="checkbox"
checked={visibility === 'public'}
disabled={isSubmitting}
className="cursor-pointer"
onChange={(e) =>
setVisibility(e.target.checked ? 'public' : 'unlisted')
}
/>
</Row>
<Spacer h={6} /> <Spacer h={6} />
<div className="form-control mb-1 items-start"> <div className="form-control mb-1 items-start">

View File

@ -1,7 +1,7 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { DOMAIN } from 'common/envs/constants' import { DOMAIN } from 'common/envs/constants'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { BetInline } from 'web/components/bet-inline' import { BetInline } from 'web/components/bet-inline'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { import {
@ -20,7 +20,6 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
import { listAllBets } from 'web/lib/firebase/bets' import { listAllBets } from 'web/lib/firebase/bets'
import { import {
contractPath, contractPath,
@ -28,6 +27,7 @@ import {
tradingAllowed, tradingAllowed,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import Custom404 from '../../404' import Custom404 from '../../404'
import { track } from 'web/lib/service/analytics'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -72,11 +72,14 @@ interface EmbedProps {
export function ContractEmbed(props: EmbedProps) { export function ContractEmbed(props: EmbedProps) {
const { contract } = props const { contract } = props
useTracking('view market embed', { useEffect(() => {
slug: contract.slug, track('view market embed', {
contractId: contract.id, slug: contract.slug,
creatorId: contract.creatorId, contractId: contract.id,
}) creatorId: contract.creatorId,
hostname: window.location.hostname,
})
}, [contract.creatorId, contract.id, contract.slug])
// return (height < 250px) ? Card : SmolView // return (height < 250px) ? Card : SmolView
return ( return (
@ -104,7 +107,7 @@ function ContractSmolView({ contract, bets }: EmbedProps) {
const href = `https://${DOMAIN}${contractPath(contract)}` const href = `https://${DOMAIN}${contractPath(contract)}`
const { setElem, height: graphHeight } = useMeasureSize() const { setElem, width: graphWidth, height: graphHeight } = useMeasureSize()
const [betPanelOpen, setBetPanelOpen] = useState(false) const [betPanelOpen, setBetPanelOpen] = useState(false)
@ -157,7 +160,14 @@ function ContractSmolView({ contract, bets }: EmbedProps) {
)} )}
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}> <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
<ContractChart contract={contract} bets={bets} height={graphHeight} /> {graphWidth != null && graphHeight != null && (
<ContractChart
contract={contract}
bets={bets}
width={graphWidth}
height={graphHeight}
/>
)}
</div> </div>
</Col> </Col>
) )

View File

@ -12,7 +12,6 @@ import { Dictionary, sortBy, sum } from 'lodash'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user' import { User } from 'common/user'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
@ -43,7 +42,12 @@ import { isArray, keyBy } from 'lodash'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts' import {
useContractsByDailyScoreNotBetOn,
useContractsByDailyScoreGroups,
useTrendingContracts,
useNewContracts,
} from 'web/hooks/use-contracts'
import { ProfitBadge } from 'web/components/profit-badge' import { ProfitBadge } from 'web/components/profit-badge'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
@ -71,12 +75,18 @@ export default function Home() {
} }
}, [user, sections]) }, [user, sections])
const groups = useMemberGroupsSubscription(user) const trendingContracts = useTrendingContracts(6)
const newContracts = useNewContracts(6)
const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6)
const groups = useMemberGroupsSubscription(user)
const groupContracts = useContractsByDailyScoreGroups( const groupContracts = useContractsByDailyScoreGroups(
groups?.map((g) => g.slug) groups?.map((g) => g.slug)
) )
const isLoading =
!user || !trendingContracts || !newContracts || !dailyTrendingContracts
return ( return (
<Page> <Page>
<Toaster /> <Toaster />
@ -90,11 +100,15 @@ export default function Home() {
<DailyStats user={user} /> <DailyStats user={user} />
</Row> </Row>
{!user ? ( {isLoading ? (
<LoadingIndicator /> <LoadingIndicator />
) : ( ) : (
<> <>
{sections.map((section) => renderSection(section, user))} {renderSections(user, sections, {
score: trendingContracts,
newest: newContracts,
'daily-trending': dailyTrendingContracts,
})}
<TrendingGroupsSection user={user} /> <TrendingGroupsSection user={user} />
@ -118,8 +132,8 @@ export default function Home() {
} }
const HOME_SECTIONS = [ const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Daily trending', id: 'daily-trending' }, { label: 'Daily trending', id: 'daily-trending' },
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'New', id: 'newest' }, { label: 'New', id: 'newest' },
] ]
@ -128,11 +142,7 @@ export const getHomeItems = (sections: string[]) => {
// Accommodate old home sections. // Accommodate old home sections.
if (!isArray(sections)) sections = [] if (!isArray(sections)) sections = []
const items: { id: string; label: string; group?: Group }[] = [ const itemsById = keyBy(HOME_SECTIONS, 'id')
...HOME_SECTIONS,
]
const itemsById = keyBy(items, 'id')
const sectionItems = filterDefined(sections.map((id) => itemsById[id])) const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
// Add new home section items to the top. // Add new home section items to the top.
@ -140,7 +150,9 @@ export const getHomeItems = (sections: string[]) => {
...HOME_SECTIONS.filter((item) => !sectionItems.includes(item)) ...HOME_SECTIONS.filter((item) => !sectionItems.includes(item))
) )
// Add unmentioned items to the end. // Add unmentioned items to the end.
sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) sectionItems.push(
...HOME_SECTIONS.filter((item) => !sectionItems.includes(item))
)
return { return {
sections: sectionItems, sections: sectionItems,
@ -148,28 +160,46 @@ export const getHomeItems = (sections: string[]) => {
} }
} }
function renderSection(section: { id: string; label: string }, user: User) { function renderSections(
const { id, label } = section user: User,
if (id === 'daily-movers') { sections: { id: string; label: string }[],
return <DailyMoversSection key={id} userId={user.id} /> sectionContracts: {
'daily-trending': CPMMBinaryContract[]
newest: CPMMBinaryContract[]
score: CPMMBinaryContract[]
} }
if (id === 'daily-trending') ) {
return ( return (
<SearchSection <>
key={id} {sections.map((s) => {
label={label} const { id, label } = s
sort={'daily-score'} if (id === 'daily-movers') {
pill="personal" return <DailyMoversSection key={id} userId={user.id} />
user={user} }
/> if (id === 'daily-trending') {
) return (
const sort = SORTS.find((sort) => sort.value === id) <ContractsSection
if (sort) key={id}
return ( label={label}
<SearchSection key={id} label={label} sort={sort.value} user={user} /> contracts={sectionContracts[id]}
) sort="daily-score"
showProbChange
return null />
)
}
const contracts =
sectionContracts[s.id as keyof typeof sectionContracts]
return (
<ContractsSection
key={id}
label={label}
contracts={contracts}
sort={id === 'daily-trending' ? 'daily-score' : (id as Sort)}
/>
)
})}
</>
)
} }
function renderGroupSections( function renderGroupSections(
@ -237,13 +267,14 @@ function SectionHeader(props: {
) )
} }
function SearchSection(props: { function ContractsSection(props: {
label: string label: string
user: User contracts: CPMMBinaryContract[]
sort: Sort sort: Sort
pill?: string pill?: string
showProbChange?: boolean
}) { }) {
const { label, user, sort, pill } = props const { label, contracts, sort, pill, showProbChange } = props
return ( return (
<Col> <Col>
@ -251,14 +282,7 @@ function SearchSection(props: {
label={label} label={label}
href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`} href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`}
/> />
<ContractSearch <ContractsGrid contracts={contracts} cardUIOptions={{ showProbChange }} />
user={user}
defaultSort={sort}
defaultPill={pill}
noControls
maxResults={6}
persistPrefix={`home-${sort}`}
/>
</Col> </Col>
) )
} }

43
web/pages/labs/index.tsx Normal file
View File

@ -0,0 +1,43 @@
import Masonry from 'react-masonry-css'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
export default function LabsPage() {
return (
<Page>
<Title text="Manifold Labs" />
<Masonry
breakpointCols={{ default: 2, 768: 1 }}
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
<LabCard
title="Dating docs"
description="Browse dating docs or create your own"
href="/date-docs"
/>
</Masonry>
</Page>
)
}
const LabCard = (props: {
title: string
description: string
href: string
}) => {
const { title, description, href } = props
return (
<SiteLink
href={href}
className="group flex h-full w-full flex-col rounded-lg bg-white p-4 shadow-md transition-shadow duration-200 hover:no-underline hover:shadow-lg"
>
<h3 className="text-lg font-semibold group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2">
{title}
</h3>
<p className="mt-2 text-gray-600">{description}</p>
</SiteLink>
)
}

View File

@ -154,7 +154,6 @@ export function PostComment(props: {
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
@ -163,6 +162,7 @@ export function PostComment(props: {
Reply Reply
</button> </button>
)} )}
<Tipper comment={comment} tips={tips ?? {}} />
</Row> </Row>
</div> </div>
</Row> </Row>