+) => {
+ 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 = (props: {
+ data: P[]
+ w: number
+ h: number
+ color: string
+ xScale: ScaleContinuousNumeric
+ yScale: ScaleContinuousNumeric
+ onMouseOver?: (p: P | undefined) => void
+ Tooltip?: TooltipComponent
+}) => {
+ const { color, data, yScale, w, h, Tooltip } = props
+
+ const [viewXScale, setViewXScale] =
+ useState>()
+ const xScale = viewXScale ?? props.xScale
+
+ const px = useCallback((p: P) => xScale(p.x), [xScale])
+ const py0 = yScale(yScale.domain()[0])
+ const py1 = useCallback((p: P) => yScale(p.y), [yScale])
+
+ const { xAxis, yAxis } = useMemo(() => {
+ const xAxis = axisBottom(xScale).ticks(w / 100)
+ const yAxis = axisLeft(yScale).tickFormat((n) => formatPct(n, 2))
+ return { xAxis, yAxis }
+ }, [w, xScale, yScale])
+
+ const onMouseOver = useEvent(betAtPointSelector(data, xScale))
+
+ const onSelect = useEvent((ev: D3BrushEvent) => {
+ if (ev.selection) {
+ const [mouseX0, mouseX1] = ev.selection as [number, number]
+ setViewXScale(() =>
+ xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+ )
+ } else {
+ setViewXScale(undefined)
+ }
+ })
+
+ return (
+
+
+
+ )
+}
+
+export const MultiValueHistoryChart =
(props: {
+ data: P[]
+ w: number
+ h: number
+ colors: readonly string[]
+ xScale: ScaleTime
+ yScale: ScaleContinuousNumeric
+ onMouseOver?: (p: P | undefined) => void
+ Tooltip?: TooltipComponent
+ pct?: boolean
+}) => {
+ const { colors, data, yScale, w, h, Tooltip, pct } = props
+
+ const [viewXScale, setViewXScale] = useState>()
+ const xScale = viewXScale ?? props.xScale
+
+ type SP = SeriesPoint
+ const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
+ const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
+ const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
+
+ const { xAxis, yAxis } = useMemo(() => {
+ const [min, max] = yScale.domain()
+ const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
+ const xAxis = axisBottom(xScale).ticks(w / 100)
+ const yAxis = pct
+ ? axisLeft(yScale)
+ .tickValues(pctTickValues)
+ .tickFormat((n) => formatPct(n))
+ : axisLeft(yScale)
+ return { xAxis, yAxis }
+ }, [w, h, pct, xScale, yScale])
+
+ const series = useMemo(() => {
+ const d3Stack = stack()
+ .keys(range(0, Math.max(...data.map(({ y }) => y.length))))
+ .value(({ y }, o) => y[o])
+ .order(stackOrderReverse)
+ return d3Stack(data)
+ }, [data])
+
+ const onMouseOver = useEvent(betAtPointSelector(data, xScale))
+
+ const onSelect = useEvent((ev: D3BrushEvent
) => {
+ if (ev.selection) {
+ const [mouseX0, mouseX1] = ev.selection as [number, number]
+ setViewXScale(() =>
+ xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+ )
+ } else {
+ setViewXScale(undefined)
+ }
+ })
+
+ return (
+
+ {series.map((s, i) => (
+
+ ))}
+
+ )
+}
+
+export const SingleValueHistoryChart =
(props: {
+ data: P[]
+ w: number
+ h: number
+ color: string
+ xScale: ScaleTime
+ yScale: ScaleContinuousNumeric
+ onMouseOver?: (p: P | undefined) => void
+ Tooltip?: TooltipComponent
+ pct?: boolean
+}) => {
+ const { color, data, yScale, w, h, Tooltip, pct } = props
+
+ const [viewXScale, setViewXScale] = useState>()
+ const xScale = viewXScale ?? props.xScale
+
+ const px = useCallback((p: P) => xScale(p.x), [xScale])
+ const py0 = yScale(yScale.domain()[0])
+ const py1 = useCallback((p: P) => yScale(p.y), [yScale])
+
+ const { xAxis, yAxis } = useMemo(() => {
+ const [min, max] = yScale.domain()
+ const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
+ const xAxis = axisBottom(xScale).ticks(w / 100)
+ const yAxis = pct
+ ? axisLeft(yScale)
+ .tickValues(pctTickValues)
+ .tickFormat((n) => formatPct(n))
+ : axisLeft(yScale)
+ return { xAxis, yAxis }
+ }, [w, h, pct, xScale, yScale])
+
+ const onMouseOver = useEvent(betAtPointSelector(data, xScale))
+
+ const onSelect = useEvent((ev: D3BrushEvent) => {
+ if (ev.selection) {
+ const [mouseX0, mouseX1] = ev.selection as [number, number]
+ setViewXScale(() =>
+ xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+ )
+ } else {
+ setViewXScale(undefined)
+ }
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx
new file mode 100644
index 00000000..96115dc0
--- /dev/null
+++ b/web/components/charts/helpers.tsx
@@ -0,0 +1,359 @@
+import {
+ ReactNode,
+ SVGProps,
+ memo,
+ useRef,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import { pointer, select } from 'd3-selection'
+import { Axis, AxisScale } from 'd3-axis'
+import { brushX, D3BrushEvent } from 'd3-brush'
+import { area, line, curveStepAfter, CurveFactory } from 'd3-shape'
+import { nanoid } from 'nanoid'
+import dayjs from 'dayjs'
+import clsx from 'clsx'
+
+import { Contract } from 'common/contract'
+import { useMeasureSize } from 'web/hooks/use-measure-size'
+
+export type Point = { x: X; y: Y; obj?: T }
+
+export interface ContinuousScale extends AxisScale {
+ invert(n: number): T
+}
+
+export type XScale = P extends Point ? AxisScale : never
+export type YScale = P extends Point ? AxisScale : never
+
+export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
+export const MARGIN_X = MARGIN.right + MARGIN.left
+export const MARGIN_Y = MARGIN.top + MARGIN.bottom
+const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
+const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
+
+export const XAxis = (props: { w: number; h: number; axis: Axis }) => {
+ const { h, axis } = props
+ const axisRef = useRef(null)
+ useEffect(() => {
+ if (axisRef.current != null) {
+ select(axisRef.current)
+ .transition()
+ .duration(250)
+ .call(axis)
+ .select('.domain')
+ .attr('stroke-width', 0)
+ }
+ }, [h, axis])
+ return
+}
+
+export const YAxis = (props: { w: number; h: number; axis: Axis }) => {
+ const { w, h, axis } = props
+ const axisRef = useRef(null)
+ useEffect(() => {
+ if (axisRef.current != null) {
+ select(axisRef.current)
+ .transition()
+ .duration(250)
+ .call(axis)
+ .call((g) =>
+ g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
+ )
+ .select('.domain')
+ .attr('stroke-width', 0)
+ }
+ }, [w, h, axis])
+ return
+}
+
+const LinePathInternal = (
+ props: {
+ data: P[]
+ px: number | ((p: P) => number)
+ py: number | ((p: P) => number)
+ curve?: CurveFactory
+ } & SVGProps
+) => {
+ const { data, px, py, curve, ...rest } = props
+ const d3Line = line(px, py).curve(curve ?? curveStepAfter)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return
+}
+export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
+
+const AreaPathInternal =
(
+ props: {
+ data: P[]
+ px: number | ((p: P) => number)
+ py0: number | ((p: P) => number)
+ py1: number | ((p: P) => number)
+ curve?: CurveFactory
+ } & SVGProps
+) => {
+ const { data, px, py0, py1, curve, ...rest } = props
+ const d3Area = area(px, py0, py1).curve(curve ?? curveStepAfter)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return
+}
+export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
+
+export const AreaWithTopStroke =
(props: {
+ color: string
+ data: P[]
+ px: number | ((p: P) => number)
+ py0: number | ((p: P) => number)
+ py1: number | ((p: P) => number)
+ curve?: CurveFactory
+}) => {
+ const { color, data, px, py0, py1, curve } = props
+ return (
+
+
+
+
+ )
+}
+
+export const SVGChart = (props: {
+ children: ReactNode
+ w: number
+ h: number
+ xAxis: Axis
+ yAxis: Axis
+ onSelect?: (ev: D3BrushEvent) => void
+ onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
+ Tooltip?: TooltipComponent
+}) => {
+ const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
+ const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
+ const tooltipMeasure = useMeasureSize()
+ const overlayRef = useRef(null)
+ const innerW = w - MARGIN_X
+ const innerH = h - MARGIN_Y
+ const clipPathId = useMemo(() => nanoid(), [])
+
+ const justSelected = useRef(false)
+ useEffect(() => {
+ if (onSelect != null && overlayRef.current) {
+ const brush = brushX().extent([
+ [0, 0],
+ [innerW, innerH],
+ ])
+ brush.on('end', (ev) => {
+ // when we clear the brush after a selection, that would normally cause
+ // another 'end' event, so we have to suppress it with this flag
+ if (!justSelected.current) {
+ justSelected.current = true
+ onSelect(ev)
+ setMouse(undefined)
+ if (overlayRef.current) {
+ select(overlayRef.current).call(brush.clear)
+ }
+ } else {
+ justSelected.current = false
+ }
+ })
+ // mqp: shape-rendering null overrides the default d3-brush shape-rendering
+ // of `crisp-edges`, which seems to cause graphical glitches on Chrome
+ // (i.e. the bug where the area fill flickers white)
+ select(overlayRef.current)
+ .call(brush)
+ .select('.selection')
+ .attr('shape-rendering', 'null')
+ }
+ }, [innerW, innerH, onSelect])
+
+ const onPointerMove = (ev: React.PointerEvent) => {
+ if (ev.pointerType === 'mouse' && onMouseOver) {
+ const [x, y] = pointer(ev)
+ const data = onMouseOver(x, y)
+ if (data !== undefined) {
+ setMouse({ x, y, data })
+ } else {
+ setMouse(undefined)
+ }
+ }
+ }
+
+ const onPointerLeave = () => {
+ setMouse(undefined)
+ }
+
+ return (
+
+ {mouse && Tooltip && (
+
+
+
+ )}
+
+
+ )
+}
+
+export type TooltipPosition = { left: number; bottom: number }
+
+export const getTooltipPosition = (
+ mouseX: number,
+ mouseY: number,
+ containerWidth: number,
+ containerHeight: number,
+ tooltipWidth?: number,
+ tooltipHeight?: number
+) => {
+ let left = mouseX + 12
+ let bottom = containerHeight - mouseY + 12
+ if (tooltipWidth != null) {
+ const overflow = left + tooltipWidth - containerWidth
+ if (overflow > 0) {
+ left -= overflow
+ }
+ }
+ if (tooltipHeight != null) {
+ const overflow = tooltipHeight - mouseY
+ if (overflow > 0) {
+ bottom -= overflow
+ }
+ }
+ return { left, bottom }
+}
+
+export type TooltipProps = {
+ mouseX: number
+ mouseY: number
+ xScale: ContinuousScale
+ data: T
+}
+
+export type TooltipComponent = React.ComponentType>
+export const TooltipContainer = (props: {
+ setElem: (e: HTMLElement | null) => void
+ pos: TooltipPosition
+ className?: string
+ children: React.ReactNode
+}) => {
+ const { setElem, pos, className, children } = props
+ return (
+
+ {children}
+
+ )
+}
+
+export const getDateRange = (contract: Contract) => {
+ const { createdTime, closeTime, resolutionTime } = contract
+ const isClosed = !!closeTime && Date.now() > closeTime
+ const endDate = resolutionTime ?? (isClosed ? closeTime : null)
+ return [createdTime, endDate ?? null] as const
+}
+
+export const getRightmostVisibleDate = (
+ contractEnd: number | null | undefined,
+ lastActivity: number | null | undefined,
+ now: number
+) => {
+ if (contractEnd != null) {
+ return contractEnd
+ } else if (lastActivity != null) {
+ // client-DB clock divergence may cause last activity to be later than now
+ return Math.max(lastActivity, now)
+ } else {
+ return now
+ }
+}
+
+export const formatPct = (n: number, digits?: number) => {
+ return `${(n * 100).toFixed(digits ?? 0)}%`
+}
+
+export const formatDate = (
+ date: Date,
+ opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean }
+) => {
+ const { includeYear, includeHour, includeMinute } = opts
+ const d = dayjs(date)
+ const now = Date.now()
+ if (
+ d.add(1, 'minute').isAfter(now) &&
+ d.subtract(1, 'minute').isBefore(now)
+ ) {
+ return 'Now'
+ } else {
+ const dayName = d.isSame(now, 'day')
+ ? 'Today'
+ : d.add(1, 'day').isSame(now, 'day')
+ ? 'Yesterday'
+ : null
+ let format = dayName ? `[${dayName}]` : 'MMM D'
+ if (includeMinute) {
+ format += ', h:mma'
+ } else if (includeHour) {
+ format += ', ha'
+ } else if (includeYear) {
+ format += ', YYYY'
+ }
+ return d.format(format)
+ }
+}
+
+export const formatDateInRange = (d: Date, start: Date, end: Date) => {
+ const opts = {
+ includeYear: !dayjs(start).isSame(end, 'year'),
+ includeHour: dayjs(start).add(8, 'day').isAfter(end),
+ includeMinute: dayjs(end).diff(start, 'hours') < 2,
+ }
+ return formatDate(d, opts)
+}
diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx
index 3ba6f2ce..6304b58d 100644
--- a/web/components/comment-input.tsx
+++ b/web/components/comment-input.tsx
@@ -126,7 +126,7 @@ export function CommentInputTextArea(props: {
{user && !isSubmitting && (
>
)
}
@@ -91,18 +99,25 @@ export function ResolveConfirmationButton(props: {
isSubmitting: boolean
openModalButtonClass?: string
submitButtonClass?: string
+ color?: ColorType
+ disabled?: boolean
}) {
- const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } =
- props
+ const {
+ onResolve,
+ isSubmitting,
+ openModalButtonClass,
+ submitButtonClass,
+ color,
+ disabled,
+ } = props
return (
void
hideOrderSelector?: boolean
cardUIOptions?: {
diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx
index ea08de01..ea0505a8 100644
--- a/web/components/contract-select-modal.tsx
+++ b/web/components/contract-select-modal.tsx
@@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true,
}}
highlightOptions={{
- contractIds: contracts.map((c) => c.id),
+ itemIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
diff --git a/web/components/contract/add-comment-bounty.tsx b/web/components/contract/add-comment-bounty.tsx
new file mode 100644
index 00000000..8b716e71
--- /dev/null
+++ b/web/components/contract/add-comment-bounty.tsx
@@ -0,0 +1,74 @@
+import { Contract } from 'common/contract'
+import { useUser } from 'web/hooks/use-user'
+import { useState } from 'react'
+import { addCommentBounty } from 'web/lib/firebase/api'
+import { track } from 'web/lib/service/analytics'
+import { Row } from 'web/components/layout/row'
+import clsx from 'clsx'
+import { formatMoney } from 'common/util/format'
+import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
+import { Button } from 'web/components/button'
+
+export function AddCommentBountyPanel(props: { contract: Contract }) {
+ const { contract } = props
+ const { id: contractId, slug } = contract
+
+ const user = useUser()
+ const amount = COMMENT_BOUNTY_AMOUNT
+ const totalAdded = contract.openCommentBounties ?? 0
+ const [error, setError] = useState(undefined)
+ const [isSuccess, setIsSuccess] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const submit = () => {
+ if ((user?.balance ?? 0) < amount) {
+ setError('Insufficient balance')
+ return
+ }
+
+ setIsLoading(true)
+ setIsSuccess(false)
+
+ addCommentBounty({ amount, contractId })
+ .then((_) => {
+ track('offer comment bounty', {
+ amount,
+ contractId,
+ })
+ setIsSuccess(true)
+ setError(undefined)
+ setIsLoading(false)
+ })
+ .catch((_) => setError('Server error'))
+
+ track('add comment bounty', { amount, contractId, slug })
+ }
+
+ return (
+ <>
+
+ Add a {formatMoney(amount)} bounty for good comments that the creator
+ can award.{' '}
+ {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
+
+
+
+
+ {error}
+
+
+ {isSuccess && amount && (
+ Success! Added {formatMoney(amount)} in bounties.
+ )}
+
+ {isLoading && Processing...
}
+ >
+ )
+}
diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx
new file mode 100644
index 00000000..79589990
--- /dev/null
+++ b/web/components/contract/bountied-contract-badge.tsx
@@ -0,0 +1,47 @@
+import { CurrencyDollarIcon } from '@heroicons/react/outline'
+import { Contract } from 'common/contract'
+import { Tooltip } from 'web/components/tooltip'
+import { formatMoney } from 'common/util/format'
+import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
+
+export function BountiedContractBadge() {
+ return (
+
+ Bounty
+
+ )
+}
+
+export function BountiedContractSmallBadge(props: {
+ contract: Contract
+ showAmount?: boolean
+}) {
+ const { contract, showAmount } = props
+ const { openCommentBounties } = contract
+ if (!openCommentBounties) return
+
+ return (
+
+
+
+ {showAmount && formatMoney(openCommentBounties)} Bounty
+
+
+ )
+}
+
+export const CommentBountiesTooltipText = (
+ creator: string,
+ openCommentBounties: number
+) =>
+ `${creator} may award ${formatMoney(
+ COMMENT_BOUNTY_AMOUNT
+ )} for good comments. ${formatMoney(
+ openCommentBounties
+ )} currently available.`
diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx
index 2b2fbcda..b783b4e0 100644
--- a/web/components/contract/contract-card.tsx
+++ b/web/components/contract/contract-card.tsx
@@ -46,6 +46,7 @@ export function ContractCard(props: {
hideGroupLink?: boolean
trackingPostfix?: string
noLinkAvatar?: boolean
+ newTab?: boolean
}) {
const {
showTime,
@@ -56,6 +57,7 @@ export function ContractCard(props: {
hideGroupLink,
trackingPostfix,
noLinkAvatar,
+ newTab,
} = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
@@ -189,6 +191,7 @@ export function ContractCard(props: {
}
)}
className="absolute top-0 left-0 right-0 bottom-0"
+ target={newTab ? '_blank' : '_self'}
/>
)}
@@ -211,7 +214,9 @@ export function BinaryResolutionOrChance(props: {
const probChanged = before !== after
return (
-
+
{resolution ? (
diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx
index 229cc364..06fce6aa 100644
--- a/web/components/contract/contract-details.tsx
+++ b/web/components/contract/contract-details.tsx
@@ -32,6 +32,10 @@ import { ExclamationIcon, PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile'
+import {
+ BountiedContractBadge,
+ BountiedContractSmallBadge,
+} from 'web/components/contract/bountied-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date'
@@ -63,6 +67,8 @@ export function MiscDetails(props: {
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
+ ) : (contract.openCommentBounties ?? 0) > 0 ? (
+
) : volume > 0 || !isNew ? (
{formatMoney(volume)} bet
) : (
@@ -126,9 +132,10 @@ export function ContractDetails(props: {
{/* GROUPS */}
{isMobile && (
-
+
+
-
+
)}
)
@@ -180,14 +187,18 @@ export function MarketSubheader(props: {
)}
-
+
{!isMobile && (
-
+
+
+
+
)}
@@ -199,8 +210,9 @@ export function CloseOrResolveTime(props: {
contract: Contract
resolvedDate: any
isCreator: boolean
+ disabled?: boolean
}) {
- const { contract, resolvedDate, isCreator } = props
+ const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) {
return (
@@ -224,6 +236,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
+ disabled={disabled}
/>
)}
@@ -244,7 +257,8 @@ export function MarketGroups(props: {
return (
<>
-
+
+
{!disabled && user && (
@@ -208,28 +219,32 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void
}) {
const user = useUser()
+ const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } =
+ props
+ const { openCommentBounties } = contract
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnContract(
- props.contract.id,
+ contract.id,
editor.getJSON(),
user,
- props.parentAnswerOutcome,
- props.parentCommentId
+ !!openCommentBounties,
+ parentAnswerOutcome,
+ parentCommentId
)
props.onSubmitComment?.()
}
return (
)
}
diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx
index e0324c4e..e8376b4b 100644
--- a/web/components/groups/create-group-button.tsx
+++ b/web/components/groups/create-group-button.tsx
@@ -82,11 +82,8 @@ export function CreateGroupButton(props: {
openModalBtn={{
label: label ? label : 'Create Group',
icon: icon,
- className: clsx(
- isSubmitting ? 'loading btn-disabled' : 'btn-primary',
- 'btn-sm, normal-case',
- className
- ),
+ className: className,
+ disabled: isSubmitting,
}}
submitBtn={{
label: 'Create',
diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-overview-post.tsx
similarity index 98%
rename from web/components/groups/group-about-post.tsx
rename to web/components/groups/group-overview-post.tsx
index 4d3046e9..55f0efca 100644
--- a/web/components/groups/group-about-post.tsx
+++ b/web/components/groups/group-overview-post.tsx
@@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react'
import { usePost } from 'web/hooks/use-post'
-export function GroupAboutPost(props: {
+export function GroupOverviewPost(props: {
group: Group
isEditable: boolean
post: Post | null
diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx
new file mode 100644
index 00000000..9b0f7240
--- /dev/null
+++ b/web/components/groups/group-overview.tsx
@@ -0,0 +1,378 @@
+import { track } from '@amplitude/analytics-browser'
+import {
+ ArrowSmRightIcon,
+ PlusCircleIcon,
+ XCircleIcon,
+} from '@heroicons/react/outline'
+
+import PencilIcon from '@heroicons/react/solid/PencilIcon'
+
+import { Contract } from 'common/contract'
+import { Group } from 'common/group'
+import { Post } from 'common/post'
+import { useEffect, useState } from 'react'
+import { ReactNode } from 'react'
+import { getPost } from 'web/lib/firebase/posts'
+import { ContractSearch } from '../contract-search'
+import { ContractCard } from '../contract/contract-card'
+
+import Masonry from 'react-masonry-css'
+
+import { Col } from '../layout/col'
+import { Row } from '../layout/row'
+import { SiteLink } from '../site-link'
+import { GroupOverviewPost } from './group-overview-post'
+import { getContractFromId } from 'web/lib/firebase/contracts'
+import { groupPath, updateGroup } from 'web/lib/firebase/groups'
+import { PinnedSelectModal } from '../pinned-select-modal'
+import { Button } from '../button'
+import { User } from 'common/user'
+import { UserLink } from '../user-link'
+import { EditGroupButton } from './edit-group-button'
+import { JoinOrLeaveGroupButton } from './groups-button'
+import { Linkify } from '../linkify'
+import { ChoicesToggleGroup } from '../choices-toggle-group'
+import { CopyLinkButton } from '../copy-link-button'
+import { REFERRAL_AMOUNT } from 'common/economy'
+import toast from 'react-hot-toast'
+import { ENV_CONFIG } from 'common/envs/constants'
+import { PostCard } from '../post-card'
+
+const MAX_TRENDING_POSTS = 6
+
+export function GroupOverview(props: {
+ group: Group
+ isEditable: boolean
+ posts: Post[]
+ aboutPost: Post | null
+ creator: User
+ user: User | null | undefined
+ memberIds: string[]
+}) {
+ const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
+ props
+ return (
+
+
+
+ {(group.aboutPostId != null || isEditable) && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ )
+}
+
+function GroupOverviewPinned(props: {
+ group: Group
+ posts: Post[]
+ isEditable: boolean
+}) {
+ const { group, posts, isEditable } = props
+ const [pinned, setPinned] = useState([])
+ const [open, setOpen] = useState(false)
+ const [editMode, setEditMode] = useState(false)
+
+ useEffect(() => {
+ async function getPinned() {
+ if (group.pinnedItems == null) {
+ updateGroup(group, { pinnedItems: [] })
+ } else {
+ const itemComponents = await Promise.all(
+ group.pinnedItems.map(async (element) => {
+ if (element.type === 'post') {
+ const post = await getPost(element.itemId)
+ if (post) {
+ return
+ }
+ } else if (element.type === 'contract') {
+ const contract = await getContractFromId(element.itemId)
+ if (contract) {
+ return
+ }
+ }
+ })
+ )
+ setPinned(
+ itemComponents.filter(
+ (element) => element != undefined
+ ) as JSX.Element[]
+ )
+ }
+ }
+ getPinned()
+ }, [group, group.pinnedItems])
+
+ async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
+ await updateGroup(group, {
+ pinnedItems: [
+ ...group.pinnedItems,
+ ...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
+ ],
+ })
+ setOpen(false)
+ }
+
+ return isEditable || pinned.length > 0 ? (
+ <>
+
+
+ {isEditable && (
+ {
+ setEditMode(!editMode)
+ }}
+ >
+ {editMode ? (
+ 'Done'
+ ) : (
+ <>
+
+ Edit
+ >
+ )}
+
+ )}
+
+
+
+ {pinned.length == 0 && !editMode && (
+
+
+ No pinned items yet. Click the edit button to add some!
+
+
+ )}
+ {pinned.map((element, index) => (
+
+ {element}
+
+ {editMode && (
+ {
+ const newPinned = group.pinnedItems.filter((item) => {
+ return item.itemId !== group.pinnedItems[index].itemId
+ })
+ updateGroup(group, { pinnedItems: newPinned })
+ }}
+ />
+ )}
+
+ ))}
+ {editMode && group.pinnedItems && pinned.length < 6 && (
+
+
+ setOpen(true)}
+ >
+
+
+
+
+ )}
+
+
+
+ Pin posts or markets to the overview of this group.
+
+ }
+ onSubmit={onSubmit}
+ />
+ >
+ ) : (
+ <>>
+ )
+}
+
+function SectionHeader(props: {
+ label: string
+ href?: string
+ children?: ReactNode
+}) {
+ const { label, href, children } = props
+ const content = (
+ <>
+ {label}{' '}
+
+ >
+ )
+
+ return (
+
+ {href ? (
+ track('group click section header', { section: href })}
+ >
+ {content}
+
+ ) : (
+ {content}
+ )}
+ {children}
+
+ )
+}
+
+export function GroupAbout(props: {
+ group: Group
+ creator: User
+ user: User | null | undefined
+ isEditable: boolean
+ memberIds: string[]
+}) {
+ const { group, creator, isEditable, user, memberIds } = props
+ const anyoneCanJoinChoices: { [key: string]: string } = {
+ Closed: 'false',
+ Open: 'true',
+ }
+ const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
+ function updateAnyoneCanJoin(newVal: boolean) {
+ if (group.anyoneCanJoin == newVal || !isEditable) return
+ setAnyoneCanJoin(newVal)
+ toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
+ loading: 'Updating group...',
+ success: 'Updated group!',
+ error: "Couldn't update group",
+ })
+ }
+ const postFix = user ? '?referrer=' + user.username : ''
+ const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
+ group.slug
+ )}${postFix}`
+ const isMember = user ? memberIds.includes(user.id) : false
+
+ return (
+ <>
+
+
+
+ {isEditable ? (
+
+ ) : (
+ user && (
+
+
+
+ )
+ )}
+
+
+
+
+
+ Membership
+ {user && user.id === creator.id ? (
+
+ updateAnyoneCanJoin(choice.toString() === 'true')
+ }
+ toggleClassName={'h-10'}
+ className={'ml-2'}
+ />
+ ) : (
+
+ {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
+
+ )}
+
+
+ {anyoneCanJoin && user && (
+
+ Invite
+
+ Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
+ sign up!
+
+
+
+
+ )}
+
+ >
+ )
+}
+
+function CrossIcon(props: { onClick: () => void }) {
+ const { onClick } = props
+
+ return (
+
+ )
+}
diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx
index a04a91af..caeb5f7d 100644
--- a/web/components/groups/group-selector.tsx
+++ b/web/components/groups/group-selector.tsx
@@ -32,27 +32,27 @@ export function GroupSelector(props: {
const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
- const availableGroups = openGroups
- .concat(
- (memberGroups ?? []).filter(
- (g) => !openGroups.map((og) => og.id).includes(g.id)
- )
- )
- .filter((group) => !ignoreGroupIds?.includes(group.id))
- .sort((a, b) => b.totalContracts - a.totalContracts)
- // put the groups the user is a member of first
- .sort((a, b) => {
- if (memberGroupIds.includes(a.id)) {
- return -1
- }
- if (memberGroupIds.includes(b.id)) {
- return 1
- }
- return 0
- })
- const filteredGroups = availableGroups.filter((group) =>
- searchInAny(query, group.name)
+ const sortGroups = (groups: Group[]) =>
+ groups.sort(
+ (a, b) =>
+ // weight group higher if user is a member
+ (memberGroupIds.includes(b.id) ? 5 : 1) * b.totalContracts -
+ (memberGroupIds.includes(a.id) ? 5 : 1) * a.totalContracts
+ )
+
+ const availableGroups = sortGroups(
+ openGroups
+ .concat(
+ (memberGroups ?? []).filter(
+ (g) => !openGroups.map((og) => og.id).includes(g.id)
+ )
+ )
+ .filter((group) => !ignoreGroupIds?.includes(group.id))
+ )
+
+ const filteredGroups = sortGroups(
+ availableGroups.filter((group) => searchInAny(query, group.name))
)
if (!showSelector || !creator) {
diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx
index b82131ec..deff2203 100644
--- a/web/components/layout/tabs.tsx
+++ b/web/components/layout/tabs.tsx
@@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react'
import { track } from '@amplitude/analytics-browser'
import { Col } from './col'
+import { Tooltip } from 'web/components/tooltip'
+import { Row } from 'web/components/layout/row'
type Tab = {
title: string
- tabIcon?: ReactNode
content: ReactNode
- // If set, show a badge with this content
- badge?: string
+ stackedTabIcon?: ReactNode
+ inlineTabIcon?: ReactNode
+ tooltip?: string
}
type TabProps = {
@@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)}
aria-current={activeIndex === i ? 'page' : undefined}
>
- {tab.badge ? (
- {tab.badge}
- ) : null}
- {tab.tabIcon && {tab.tabIcon}
}
- {tab.title}
+
+ {tab.stackedTabIcon && (
+ {tab.stackedTabIcon}
+ )}
+
+ {tab.title}
+ {tab.inlineTabIcon}
+
+
))}
diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx
index 606bc7e0..bd9ed246 100644
--- a/web/components/limit-bets.tsx
+++ b/web/components/limit-bets.tsx
@@ -182,7 +182,7 @@ export function OrderBookButton(props: {
size="xs"
color="blue"
>
- Order book
+ {limitBets.length} Limit orders