Merge branch 'main' into embed-sizing
This commit is contained in:
commit
241fc7a802
|
@ -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
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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.'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
61
web/components/contract/tip-button.tsx
Normal file
61
web/components/contract/tip-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
)
|
)
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
43
web/pages/labs/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user