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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react'
import { useMemo } from 'react'
import { range } from 'lodash'
import { scaleLinear } from 'd3-scale'
@ -6,10 +6,8 @@ import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract'
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 { DistributionPoint, DistributionChart } from '../generic-charts'
import { useElementWidth } from 'web/hooks/use-element-width'
const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract
@ -21,45 +19,41 @@ const getNumericChartData = (contract: NumericContract) => {
}))
}
const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => {
const { x, y } = props.p
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return (
<>
<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: {
contract: NumericContract
height?: number
width: number
height: number
onMouseOver?: (p: DistributionPoint | undefined) => void
}) => {
const { contract, onMouseOver } = props
const { contract, width, height, onMouseOver } = props
const { min, max } = 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 xScale = scaleLinear([min, max], [0, width - MARGIN_X])
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
return (
<div ref={containerRef}>
{width > 0 && (
<DistributionChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
color={NUMERIC_GRAPH_COLOR}
onMouseOver={onMouseOver}
Tooltip={NumericChartTooltip}
/>
)}
</div>
<DistributionChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
color={NUMERIC_GRAPH_COLOR}
onMouseOver={onMouseOver}
Tooltip={NumericChartTooltip}
/>
)
}

View File

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

View File

@ -13,6 +13,7 @@ import {
import { range } from 'lodash'
import {
ContinuousScale,
SVGChart,
AreaPath,
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]
}
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: {
data: P[]
w: number
@ -39,7 +53,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<number, P>
}) => {
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 py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100)
@ -58,6 +71,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis }
}, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}
@ -107,7 +114,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
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 py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
@ -141,6 +147,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data)
}, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}
@ -193,7 +193,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
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 py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
@ -218,6 +217,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}

View File

@ -16,8 +16,14 @@ import dayjs from 'dayjs'
import clsx from 'clsx'
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 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
w: number
h: number
xAxis: Axis<X>
yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined
Tooltip?: TooltipComponent<P>
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<X, TT>
}) => {
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 innerW = w - MARGIN_X
const innerH = h - MARGIN_Y
@ -148,7 +155,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouseState(undefined)
setMouse(undefined)
if (overlayRef.current) {
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) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev)
const p = onMouseOver(mouseX, mouseY)
if (p != null) {
const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH)
setMouseState({ pos, p })
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouseState(undefined)
setMouse(undefined)
}
}
}
const onPointerLeave = () => {
setMouseState(undefined)
setMouse(undefined)
}
return (
<div className="relative">
{mouseState && Tooltip && (
<TooltipContainer pos={mouseState.pos}>
<Tooltip xScale={xAxis.scale()} p={mouseState.p} />
<div className="relative overflow-hidden">
{mouse && Tooltip && (
<TooltipContainer
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>
)}
<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 = {
top?: number
right?: number
bottom?: number
left?: number
}
export type TooltipPosition = { left: number; bottom: number }
export const getTooltipPosition = (
mouseX: number,
mouseY: number,
w: number,
h: number
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
) => {
const result: TooltipPosition = {}
if (mouseX <= (3 * w) / 4) {
result.left = mouseX + 10 // in the left three quarters
} else {
result.right = w - mouseX + 10 // in the right quarter
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (mouseY <= h / 4) {
result.top = mouseY + 10 // in the top quarter
} else {
result.bottom = h - mouseY + 10 // in the bottom three quarters
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return result
return { left, bottom }
}
export type TooltipProps<P> = { p: P; xScale: XScale<P> }
export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>>
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition
className?: string
children: React.ReactNode
}) => {
const { pos, className, children } = props
const { setElem, pos, className, children } = props
return (
<div
ref={setElem}
className={clsx(
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'

View File

@ -22,7 +22,10 @@ export function BountiedContractSmallBadge(props: {
return (
<Tooltip
text={CommentBountiesTooltipText(openCommentBounties)}
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
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">
@ -33,8 +36,11 @@ export function BountiedContractSmallBadge(props: {
)
}
export const CommentBountiesTooltipText = (openCommentBounties: number) =>
`The creator of this market may award ${formatMoney(
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties

View File

@ -183,6 +183,7 @@ export function MarketSubheader(props: {
contract={contract}
resolvedDate={resolvedDate}
isCreator={isCreator}
disabled={disabled}
/>
{!isMobile && (
<Row className={'gap-1'}>
@ -200,8 +201,9 @@ export function CloseOrResolveTime(props: {
contract: Contract
resolvedDate: any
isCreator: boolean
disabled?: boolean
}) {
const { contract, resolvedDate, isCreator } = props
const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) {
return (
@ -225,6 +227,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
disabled={disabled}
/>
</Row>
)}
@ -245,7 +248,8 @@ export function MarketGroups(props: {
return (
<>
<Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} />
<GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && (
<button
className="text-greyscale-4 hover:text-greyscale-3"
@ -330,14 +334,29 @@ export function ExtraMobileContractDetails(props: {
)
}
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) {
const { groupToDisplay } = props
export function GroupDisplay(props: {
groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
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)}>
<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]">
{groupToDisplay.name}
</a>
{groupSection}
</Link>
)
} else
@ -352,8 +371,9 @@ function EditableCloseDate(props: {
closeTime: number
contract: Contract
isCreator: boolean
disabled?: boolean
}) {
const { closeTime, contract, isCreator } = props
const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs()
@ -452,8 +472,8 @@ function EditableCloseDate(props: {
time={closeTime}
>
<span
className={isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)}
className={!disabled && isCreator ? 'cursor-pointer' : ''}
onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
>
{isSameDay ? (
<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 { Col } from '../layout/col'
import {
BinaryContractChart,
NumericContractChart,
PseudoNumericContractChart,
ChoiceContractChart,
} from 'web/components/charts/contract'
import { ContractChart } from 'web/components/charts/contract'
import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
@ -48,8 +43,43 @@ const BetWidget = (props: { contract: CPMMContract }) => {
)
}
const NumericOverview = (props: { contract: NumericContract }) => {
const { contract } = props
const SizedContractChart = (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 (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
@ -66,7 +96,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract}
/>
</Col>
<NumericContractChart contract={contract} />
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col>
)
}
@ -82,7 +117,12 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<BinaryResolutionOrChance contract={contract} large />
</Row>
</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">
{tradingAllowed(contract) && (
<BinaryMobileBetting contract={contract} />
@ -107,9 +147,12 @@ const ChoiceOverview = (props: {
<FreeResponseResolutionOrChance contract={contract} truncate="none" />
)}
</Col>
<Col className={'mb-1 gap-y-2'}>
<ChoiceContractChart contract={contract} bets={bets} />
</Col>
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col>
)
}
@ -135,7 +178,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row>
</Col>
<PseudoNumericContractChart contract={contract} bets={bets} />
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col>
)
}
@ -149,7 +197,7 @@ export const ContractOverview = (props: {
case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC':
return <NumericOverview contract={contract} />
return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE':

View File

@ -75,7 +75,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = useState<'Newest' | 'Best'>('Best')
const [sort, setSort] = useState<'Newest' | 'Best'>('Newest')
const me = useUser()
if (comments == null) {
return <LoadingIndicator />
@ -159,7 +159,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<Tooltip
text={
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 (
<Row>
<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>
<Button
size="sm"

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes'
@ -8,74 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes'
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 { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
import { Tooltip } from '../tooltip'
import { TipButton } from './tip-button'
export function LikeMarketButton(props: {
contract: Contract
user: User | null | undefined
}) {
const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id
)
const tips = useMarketTipTxns(contract.id)
const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount))
}, [tips])
const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes
?.filter((l) => l.type === 'contract')
.map((l) => l.id)
const onLike = async () => {
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)}!`)
}
return (
<Tooltip
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<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>
<TipButton
onClick={onLike}
tipAmount={LIKE_TIP_AMOUNT}
totalTipped={totalTipped}
userTipped={
!!user &&
(isLiking ||
userLikedContractIds?.includes(contract.id) ||
(!likes && !!contract.likedByUserIds?.includes(user.id)))
}
disabled={contract.creatorId === user?.id}
/>
)
}

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
/>
<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 && (
<button
className="font-bold hover:underline"
@ -189,6 +185,10 @@ export function FeedComment(props: {
Reply
</button>
)}
{tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</div>
</Row>

View File

@ -32,27 +32,27 @@ export function GroupSelector(props: {
const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.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) =>
searchInAny(query, group.name)
const sortGroups = (groups: Group[]) =>
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) {

View File

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

View File

@ -1,22 +1,17 @@
import {
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { debounce, sum } from 'lodash'
import { Comment } from 'common/comment'
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 { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { TipButton } from './contract/tip-button'
import { Row } from './layout/row'
import { Tooltip } from './tooltip'
const TIP_SIZE = 10
import { LIKE_TIP_AMOUNT } from 'common/like'
import { formatMoney } from 'common/util/format'
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop
@ -26,6 +21,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const savedTip = tips[myId] ?? 0
const [localTip, setLocalTip] = useState(savedTip)
// listen for user being set
const initialized = useRef(false)
useEffect(() => {
@ -78,71 +74,22 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const addTip = (delta: number) => {
setLocalTip(localTip + delta)
me && saveTip(me, comment, localTip - savedTip + delta)
toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
}
const canDown = me && localTip > savedTip
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
const canUp =
me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT
return (
<Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} />
<span className="font-bold">{Math.floor(total)}</span>
<UpTip
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined}
value={localTip}
<TipButton
tipAmount={LIKE_TIP_AMOUNT}
totalTipped={total}
onClick={() => addTip(+LIKE_TIP_AMOUNT)}
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>
)
}
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,
getUserBetContractsQuery,
listAllContracts,
trendingContractsQuery,
} from 'web/lib/firebase/contracts'
import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS, sleep } from 'common/util/time'
import { query, limit } from 'firebase/firestore'
import { dailyScoreIndex } from 'web/lib/service/algolia'
import {
dailyScoreIndex,
newIndex,
trendingIndex,
} from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash'
@ -27,16 +29,50 @@ export const useContracts = () => {
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 = (
groupSlugs: string[] | undefined
) => {
const facetFilters = ['isResolved:false']
const { data } = useQuery(['daily-score', groupSlugs], () =>
Promise.all(
(groupSlugs ?? []).map((slug) =>
dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: [...facetFilters, `groupLinks.slug:${slug}`],
facetFilters: ['isResolved:false', `groupLinks.slug:${slug}`],
})
)
)
@ -56,14 +92,6 @@ export const getCachedContracts = async () =>
staleTime: Infinity,
})
export const useTrendingContracts = (maxContracts: number) => {
const result = useFirestoreQueryData(
['trending-contracts', maxContracts],
query(trendingContractsQuery, limit(maxContracts))
)
return result.data
}
export const useInactiveContracts = () => {
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 { track } from 'web/lib/service/analytics'
import { inIframe } from './use-is-iframe'
export const useTracking = (
@ -10,5 +10,5 @@ export const useTracking = (
useEffect(() => {
if (excludeIframe && inIframe()) return
track(eventName, eventProperties)
}, [])
}, [eventName, eventProperties, excludeIframe])
}

View File

@ -14,6 +14,8 @@ export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}`
}
export const trendingIndex = searchClient.initIndex(getIndexName('score'))
export const newIndex = searchClient.initIndex(getIndexName('newest'))
export const probChangeDescendingIndex = searchClient.initIndex(
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 { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { FullMarket, ApiError, toFullMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler(
req: NextApiRequest,
@ -24,6 +25,6 @@ export default async function handler(
return
}
res.setHeader('Cache-Control', 'max-age=0')
res.setHeader('Cache-Control', marketCacheStrategy)
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 { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { ApiError, toLiteMarket, LiteMarket } from '../../_types'
import { marketCacheStrategy } from '../../markets'
export default async function handler(
req: NextApiRequest,
@ -18,6 +19,6 @@ export default async function handler(
return
}
res.setHeader('Cache-Control', 'max-age=0')
res.setHeader('Cache-Control', marketCacheStrategy)
return res.status(200).json(toLiteMarket(contract))
}

View File

@ -6,6 +6,8 @@ import { toLiteMarket, ValidationError } from './_types'
import { z } from 'zod'
import { validate } from './_validate'
export const marketCacheStrategy = 's-maxage=15, stale-while-revalidate=45'
const queryParams = z
.object({
limit: z
@ -39,7 +41,7 @@ export default async function handler(
try {
const contracts = await listAllContracts(limit, before)
// 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))
} catch (e) {
res.status(400).json({

View File

@ -280,25 +280,27 @@ export function NewContract(props: {
<label className="label">
<span className="mb-1">Answer type</span>
</label>
<ChoicesToggleGroup
currentChoice={outcomeType}
setChoice={(choice) => {
if (choice === 'FREE_RESPONSE')
setMarketInfoText(
'Users can submit their own answers to this market.'
)
else setMarketInfoText('')
setOutcomeType(choice as outcomeType)
}}
choicesMap={{
'Yes / No': 'BINARY',
// 'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE',
// Numeric: 'PSEUDO_NUMERIC',
}}
isSubmitting={isSubmitting}
className={'col-span-4'}
/>
<Row>
<ChoicesToggleGroup
currentChoice={outcomeType}
setChoice={(choice) => {
if (choice === 'FREE_RESPONSE')
setMarketInfoText(
'Users can submit their own answers to this market.'
)
else setMarketInfoText('')
setOutcomeType(choice as outcomeType)
}}
choicesMap={{
'Yes / No': 'BINARY',
// 'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE',
// Numeric: 'PSEUDO_NUMERIC',
}}
isSubmitting={isSubmitting}
className={'col-span-4'}
/>
</Row>
{marketInfoText && (
<div className="mt-3 ml-1 text-sm text-indigo-700">
{marketInfoText}
@ -390,23 +392,7 @@ export function NewContract(props: {
</>
)}
<div className="form-control mb-1 items-start gap-1">
<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} />
<Spacer h={4} />
<Row className={'items-end gap-x-2'}>
<GroupSelector
@ -421,6 +407,20 @@ export function NewContract(props: {
</SiteLink>
)}
</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} />
<div className="form-control mb-1 items-start">

View File

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

View File

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