Merge remote-tracking branch 'remotes/origin/main' into twitch-linking

This commit is contained in:
Phil 2022-09-08 17:13:46 +01:00
commit 49a30dc237
85 changed files with 1956 additions and 894 deletions

View File

@ -1,4 +1,4 @@
import { sortBy, sum, sumBy } from 'lodash'
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
@ -36,6 +36,33 @@ export const computeVolume = (contractBets: Bet[], since: number) => {
)
}
const calculateProbChangeSince = (descendingBets: Bet[], since: number) => {
const newestBet = descendingBets[0]
if (!newestBet) return 0
const betBeforeSince = descendingBets.find((b) => b.createdTime < since)
if (!betBeforeSince) {
const oldestBet = last(descendingBets) ?? newestBet
return newestBet.probAfter - oldestBet.probBefore
}
return newestBet.probAfter - betBeforeSince.probAfter
}
export const calculateProbChanges = (descendingBets: Bet[]) => {
const now = Date.now()
const yesterday = now - DAY_MS
const weekAgo = now - 7 * DAY_MS
const monthAgo = now - 30 * DAY_MS
return {
day: calculateProbChangeSince(descendingBets, yesterday),
week: calculateProbChangeSince(descendingBets, weekAgo),
month: calculateProbChangeSince(descendingBets, monthAgo),
}
}
export const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
@ -89,12 +116,12 @@ const calculateProfitForPeriod = (
return currentProfit
}
const startingProfit = calculateTotalProfit(startingPortfolio)
const startingProfit = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
@ -102,7 +129,7 @@ export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculateTotalProfit(newPortfolio)
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp

View File

@ -1,6 +1,6 @@
import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup
export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId.
@ -20,19 +20,31 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string
} & T
type OnContract = {
export type OnContract = {
commentType: 'contract'
contractId: string
contractSlug: string
contractQuestion: string
answerOutcome?: string
betId?: string
// denormalized from contract
contractSlug: string
contractQuestion: string
// denormalized from bet
betAmount?: number
betOutcome?: string
}
type OnGroup = {
export type OnGroup = {
commentType: 'group'
groupId: string
}
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -87,6 +87,12 @@ export type CPMM = {
pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$
prob: number
probChanges: {
day: number
week: number
month: number
}
}
export type Binary = {

View File

@ -12,10 +12,6 @@ export type Group = {
aboutPostId?: string
chatDisabled?: boolean
mostRecentContractAddedTime?: number
/** @deprecated - members and contracts now stored as subcollections*/
memberIds?: string[] // Deprecated
/** @deprecated - members and contracts now stored as subcollections*/
contractIds?: string[] // Deprecated
}
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140

View File

@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
initialProbability: p,
p,
pool: pool,
prob: initialProb,
probChanges: { day: 0, week: 0, month: 0 },
}
return system

View File

@ -54,6 +54,10 @@ Returns the authenticated user.
Gets all groups, in no particular order.
Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned.
Requires no authorization.
### `GET /v0/groups/[slug]`
@ -62,12 +66,18 @@ Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
### `GET /v0/markets`
Lists all markets, ordered by creation date descending.

View File

@ -12,7 +12,9 @@ service cloud.firestore {
'taowell@gmail.com',
'abc.sinclair@gmail.com',
'manticmarkets@gmail.com',
'iansphilips@gmail.com'
'iansphilips@gmail.com',
'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com'
]
}
@ -203,6 +205,10 @@ service cloud.firestore {
.affectedKeys()
.hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
}
}
}
}

View File

@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const comments = await getValues<ContractComment>(

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
) {
throw new APIError(400, 'Bet too large for current liquidity pool.')
throw new APIError(400, 'Trade too large for current liquidity pool.')
}
const betDoc = contractDoc.collection('bets').doc()

View File

@ -0,0 +1,69 @@
// Filling in the bet-based fields on comments.
import * as admin from 'firebase-admin'
import { zip } from 'lodash'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore'
initAdmin()
const firestore = admin.firestore()
async function getBetComments(transaction: Transaction) {
const allComments = await transaction.get(
firestore.collectionGroup('comments')
)
const betComments = allComments.docs.filter((d) => d.get('betId'))
log(`Found ${betComments.length} comments associated with bets.`)
return betComments
}
async function denormalize() {
let hasMore = true
while (hasMore) {
hasMore = await admin.firestore().runTransaction(async (trans) => {
const betComments = await getBetComments(trans)
const bets = await Promise.all(
betComments.map((doc) =>
trans.get(
firestore
.collection('contracts')
.doc(doc.get('contractId'))
.collection('bets')
.doc(doc.get('betId'))
)
)
)
log(`Found ${bets.length} bets associated with comments.`)
const mapping = zip(bets, betComments)
.map(([bet, comment]): DocumentCorrespondence => {
return [bet!, [comment!]] // eslint-disable-line
})
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
const diffs = amountDiffs.concat(outcomeDiffs)
diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d))
applyDiff(trans, d)
})
if (diffs.length > 500) {
console.log(`Applying first 500 because of Firestore limit...`)
}
return diffs.length > 500
})
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -9,84 +9,84 @@ const getGroups = async () => {
return groups.docs.map((doc) => doc.data() as Group)
}
const createContractIdForGroup = async (
groupId: string,
contractId: string
) => {
const firestore = admin.firestore()
const now = Date.now()
const contractDoc = await firestore
.collection('groups')
.doc(groupId)
.collection('groupContracts')
.doc(contractId)
.get()
if (!contractDoc.exists)
await firestore
.collection('groups')
.doc(groupId)
.collection('groupContracts')
.doc(contractId)
.create({
contractId,
createdTime: now,
})
}
// const createContractIdForGroup = async (
// groupId: string,
// contractId: string
// ) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const contractDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .get()
// if (!contractDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .create({
// contractId,
// createdTime: now,
// })
// }
const createMemberForGroup = async (groupId: string, userId: string) => {
const firestore = admin.firestore()
const now = Date.now()
const memberDoc = await firestore
.collection('groups')
.doc(groupId)
.collection('groupMembers')
.doc(userId)
.get()
if (!memberDoc.exists)
await firestore
.collection('groups')
.doc(groupId)
.collection('groupMembers')
.doc(userId)
.create({
userId,
createdTime: now,
})
}
// const createMemberForGroup = async (groupId: string, userId: string) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const memberDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .get()
// if (!memberDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .create({
// userId,
// createdTime: now,
// })
// }
// async function convertGroupFieldsToGroupDocuments() {
// const groups = await getGroups()
// for (const group of groups) {
// log('updating group', group.slug)
// const groupRef = admin.firestore().collection('groups').doc(group.id)
// const totalMembers = (await groupRef.collection('groupMembers').get()).size
// const totalContracts = (await groupRef.collection('groupContracts').get())
// .size
// if (
// totalMembers === group.memberIds?.length &&
// totalContracts === group.contractIds?.length
// ) {
// log('group already converted', group.slug)
// continue
// }
// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
// for (const contractId of group.contractIds?.slice(
// contractStart,
// group.contractIds?.length
// ) ?? []) {
// await createContractIdForGroup(group.id, contractId)
// }
// for (const userId of group.memberIds?.slice(
// membersStart,
// group.memberIds?.length
// ) ?? []) {
// await createMemberForGroup(group.id, userId)
// }
// }
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function convertGroupFieldsToGroupDocuments() {
const groups = await getGroups()
for (const group of groups) {
log('updating group', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
if (
totalMembers === group.memberIds?.length &&
totalContracts === group.contractIds?.length
) {
log('group already converted', group.slug)
continue
}
const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
for (const contractId of group.contractIds?.slice(
contractStart,
group.contractIds?.length
) ?? []) {
await createContractIdForGroup(group.id, contractId)
}
for (const userId of group.memberIds?.slice(
membersStart,
group.memberIds?.length
) ?? []) {
await createMemberForGroup(group.id, userId)
}
}
}
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
@ -101,9 +101,22 @@ async function updateTotalContractsAndMembers() {
})
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
const groups = await getGroups()
for (const group of groups) {
log('removing member and contract ids', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
await groupRef.update({
memberIds: admin.firestore.FieldValue.delete(),
contractIds: admin.firestore.FieldValue.delete(),
})
}
}
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
updateTotalContractsAndMembers()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
}

View File

@ -1,9 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, last } from 'lodash'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans'
@ -11,8 +11,10 @@ import {
calculateCreatorVolume,
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
const firestore = admin.firestore()
@ -43,11 +45,29 @@ export async function updateMetricsCore() {
.filter((contract) => contract.id)
.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
const descendingBets = sortBy(
contractBets,
(bet) => bet.createdTime
).reverse()
let cpmmFields: Partial<CPMM> = {}
if (contract.mechanism === 'cpmm-1') {
const prob = descendingBets[0]
? descendingBets[0].probAfter
: getProbability(contract)
cpmmFields = {
prob,
probChanges: calculateProbChanges(descendingBets),
}
}
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
},
}
})

View File

@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) {
? resolutionDiv
: numericValue
? numericValueDiv
: probabilityDiv
: probability
? probabilityDiv
: ''
}
</div>
</div>

View File

@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void
minimumAmount?: number
disabled?: boolean
showSliderOnMobile?: boolean
className?: string
inputClassName?: string
// Needed to focus the amount input
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
onChange,
error,
setError,
showSliderOnMobile: showSlider,
disabled,
className,
inputClassName,
@ -121,15 +123,28 @@ export function BuyAmountInput(props: {
}
return (
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
<>
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
{showSlider && (
<input
type="range"
min="0"
max="200"
value={amount ?? 0}
onChange={(e) => onAmountChange(parseInt(e.target.value))}
className="range range-lg z-40 mb-2 xl:hidden"
step="5"
/>
)}
</>
)
}

View File

@ -120,7 +120,7 @@ export function AnswerBetPanel(props: {
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch">
<div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'}
Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div>
{!isModal && (
@ -134,8 +134,9 @@ export function AnswerBetPanel(props: {
</Row>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>(balance: {formatMoney(user?.balance ?? 0)})</span>
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -144,6 +145,7 @@ export function AnswerBetPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{(betAmount ?? 0) > 10 &&
@ -204,7 +206,7 @@ export function AnswerBetPanel(props: {
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit trade'}
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
) : (
<BetSignUpPrompt />

View File

@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
return (
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
value={text}
@ -152,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>{' '}
<BuyAmountInput
@ -162,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setError={setAmountError}
minimumAmount={1}
disabled={isSubmitting}
showSliderOnMobile
/>
</Col>
<Col className="gap-3">
@ -205,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')}
>
Submit answer & buy
Submit
</button>
) : (
text && (

View File

@ -12,7 +12,7 @@ import { User } from 'common/user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null
user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: {
visible: string[]
@ -30,7 +30,6 @@ export function ArrangeHome(props: {
return (
<DragDropContext
onDragEnd={(e) => {
console.log('drag end', e)
const { destination, source, draggableId } = e
if (!destination) return
@ -112,7 +111,7 @@ export const getHomeItems = (
{ label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' },
{ label: 'Your bets', id: 'your-bets' },
{ label: 'Your trades', id: 'your-bets' },
...groups.map((g) => ({
label: g.name,
id: g.id,

View File

@ -2,6 +2,7 @@ import Router from 'next/router'
import clsx from 'clsx'
import { MouseEvent, useEffect, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
import Image from 'next/future/image'
export function Avatar(props: {
username?: string
@ -14,6 +15,7 @@ export function Avatar(props: {
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4
const onClick =
noLink && username
@ -26,7 +28,9 @@ export function Avatar(props: {
// there can be no avatar URL or username in the feed, we show a "submit comment"
// item with a fake grey user circle guy even if you aren't signed in
return avatarUrl ? (
<img
<Image
width={sizeInPx}
height={sizeInPx}
className={clsx(
'flex-shrink-0 rounded-full bg-white object-cover',
`w-${s} h-${s}`,

View File

@ -35,10 +35,13 @@ export default function BetButton(props: {
{user ? (
<Button
size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
className={clsx(
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
btnClassName
)}
onClick={() => setOpen(true)}
>
Bet
Predict
</Button>
) : (
<BetSignUpPrompt />

View File

@ -281,7 +281,7 @@ function BuyPanel(props: {
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
@ -310,9 +310,10 @@ function BuyPanel(props: {
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -321,6 +322,7 @@ function BuyPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{warning}
@ -377,11 +379,11 @@ function BuyPanel(props: {
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col>
)
}
@ -567,7 +569,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div>
<ProbabilityOrNumericInput
contract={contract}
@ -578,7 +580,7 @@ function LimitOrderPanel(props: {
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div>
<ProbabilityOrNumericInput
contract={contract}
@ -606,9 +608,10 @@ function LimitOrderPanel(props: {
Max amount<span className="ml-1 text-red-500">*</span>
</span>
<span className={'xl:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -616,6 +619,7 @@ function LimitOrderPanel(props: {
error={error}
setError={setError}
disabled={isSubmitting}
showSliderOnMobile
/>
<Col className="mt-3 w-full gap-3">
@ -746,15 +750,18 @@ function QuickOrLimitBet(props: {
return (
<Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div>
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl">
Predict
</div>
{!hideToggle && (
<Row className="mt-1 items-center gap-2">
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton
selected={!isLimitOrder}
onSelect={() => {
setIsLimitOrder(false)
track('select quick order')
}}
xs={true}
>
Quick
</PillButton>
@ -764,6 +771,7 @@ function QuickOrLimitBet(props: {
setIsLimitOrder(true)
track('select limit order')
}}
xs={true}
>
Limit
</PillButton>

View File

@ -161,7 +161,7 @@ export function BetsList(props: { user: User }) {
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col className="mt-6">
<Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
<Row className="gap-8">
<Col>

View File

@ -5,19 +5,19 @@ export function PillButton(props: {
selected: boolean
onSelect: () => void
color?: string
big?: boolean
xs?: boolean
children: ReactNode
}) {
const { children, selected, onSelect, color, big } = props
const { children, selected, onSelect, color, xs } = props
return (
<button
className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full',
'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm',
xs ? 'text-xs' : '',
selected
? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3',
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
: 'bg-greyscale-2 hover:bg-greyscale-3'
)}
onClick={onSelect}
>

View File

@ -33,12 +33,12 @@ export function Carousel(props: {
}, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, [])
useEffect(onScroll, [children])
return (
<div className={clsx('relative', className)}>
<Row
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
ref={ref}
onScroll={onScroll}
>

View File

@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
setErrorText('')
}, [open])
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
if (!user)
return <BetSignUpPrompt label="Sign up to accept" className="mt-4" />
const iAcceptChallenge = () => {
setLoading(true)

View File

@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy'
@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { useWindowSize } from 'web/hooks/use-window-size'
type challengeInfo = {
amount: number
@ -110,9 +110,8 @@ function CreateChallengeForm(props: {
const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
@ -158,7 +157,7 @@ function CreateChallengeForm(props: {
<Textarea
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
autoFocus={!isMobile}
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
onChange={(e) =>
@ -171,7 +170,7 @@ function CreateChallengeForm(props: {
)}
</div>
<Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2">
<Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
<Col>
<div>You'll bet:</div>
<Row
@ -186,7 +185,9 @@ function CreateChallengeForm(props: {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
@ -197,7 +198,7 @@ function CreateChallengeForm(props: {
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'max-w-xs justify-end'}>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
@ -212,18 +213,50 @@ function CreateChallengeForm(props: {
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row
className={'max-w-xs items-center justify-between gap-4 pr-3'}
>
<div className={'w-32 sm:mr-1'}>
<AmountInput
amount={challengeInfo.acceptorAmount || undefined}
onChange={(newAmount) => {
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: newAmount ?? 0,
}
})
}}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</Col>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'mt-1 w-32 sm:mr-1'}>
<span className={'ml-2 font-bold'}>
{formatMoney(challengeInfo.acceptorAmount)}
</span>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</Col>
{contract && (
<Button
size="2xs"
color="gray"
onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
)}
<div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold">

View File

@ -0,0 +1,175 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -43,10 +43,13 @@ export const SORTS = [
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const
export type Sort = typeof SORTS[number]['value']
@ -66,6 +69,7 @@ type AdditionalFilter = {
excludeContractIds?: string[]
groupSlug?: string
yourBets?: boolean
followed?: boolean
}
export function ContractSearch(props: {
@ -85,6 +89,7 @@ export function ContractSearch(props: {
useQueryUrlParam?: boolean
isWholePage?: boolean
noControls?: boolean
maxResults?: number
renderContracts?: (
contracts: Contract[] | undefined,
loadMore: () => void
@ -104,6 +109,7 @@ export function ContractSearch(props: {
useQueryUrlParam,
isWholePage,
noControls,
maxResults,
renderContracts,
} = props
@ -186,7 +192,8 @@ export function ContractSearch(props: {
const contracts = state.pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = state.pages.length === 0 ? undefined : contracts
const renderedContracts =
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -289,6 +296,19 @@ function ContractSearchControls(props: {
const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
@ -301,6 +321,7 @@ function ContractSearchControls(props: {
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
...(additionalFilter?.followed ? personalFilters : []),
]
const facetFilters = query
? additionalFilters
@ -317,17 +338,7 @@ function ContractSearchControls(props: {
state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}`
: '',
state.pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
: '',
// Subtract contracts you bet on from For you.
state.pillFilter === 'personal' && user
? `uniqueBettorIds:-${user.id}`
: '',
...(state.pillFilter === 'personal' ? personalFilters : []),
state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
@ -438,7 +449,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
Your trades
</PillButton>
)}

View File

@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip'
export function ContractCard(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
className?: string
questionClass?: string
@ -45,7 +44,6 @@ export function ContractCard(props: {
trackingPostfix?: string
}) {
const {
showHotVolume,
showTime,
className,
questionClass,
@ -147,7 +145,6 @@ export function ContractCard(props: {
<AvatarDetails contract={contract} short={true} className="md:hidden" />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>

View File

@ -2,7 +2,6 @@ import {
ClockIcon,
DatabaseIcon,
PencilIcon,
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date'
export function MiscDetails(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
hideGroupLink?: boolean
}) {
const { contract, showHotVolume, showTime, hideGroupLink } = props
const {
volume,
volume24Hours,
closeTime,
isResolved,
createdTime,
resolutionTime,
} = contract
const { contract, showTime, hideGroupLink } = props
const { volume, closeTime, isResolved, createdTime, resolutionTime } =
contract
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const groupToDisplay = getGroupLinkToDisplay(contract)
return (
<Row className="items-center gap-3 truncate text-sm text-gray-400">
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
{showTime === 'close-date' ? (
<Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
@ -306,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique bettors`}
)} bet - ${uniqueBettors} unique traders`}
>
{volumeTranslation}
</Tooltip>
@ -369,7 +357,7 @@ function EditableCloseDate(props: {
return (
<>
{isEditingCloseTime ? (
<Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1">
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
<input
type="date"
className="input input-bordered shrink-0"

View File

@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
</tr> */}
<tr>
<td>Bettors</td>
<td>Traders</td>
<td>{bettorsCount}</td>
</tr>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
title="🏅 Top traders"
users={users || []}
columns={[
{
@ -109,10 +109,6 @@ export function ContractTopTrades(props: {
betsBySameUser={[betsById[topCommentId]]}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
@ -120,11 +116,11 @@ export function ContractTopTrades(props: {
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<Title text="💸 Best bet" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet contract={contract} bet={betsById[topBetId]} />
</div>
<div className="mt-2 text-sm text-gray-500">
<div className="mt-2 ml-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>

View File

@ -116,13 +116,13 @@ export function ContractTabs(props: {
badge: `${comments.length}`,
},
{
title: 'Bets',
title: 'Trades',
content: betActivity,
badge: `${visibleBets.length}`,
},
...(!user || !userBets?.length
? []
: [{ title: 'Your bets', content: yourTrades }]),
: [{ title: 'Your trades', content: yourTrades }]),
]}
/>
{!user ? (

View File

@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{
creatorId: creator.id,
}}
persistPrefix={`user-${creator.id}`}
/>
)
}

View File

@ -14,6 +14,7 @@ import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props
@ -42,7 +43,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/>
<span>Share</span>
</Col>
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
@ -50,17 +50,21 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user}
/>
</Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className={'flex hidden max-w-xs self-center sm:inline-block'}
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<span> Challenge</span>
<Col className="items-center sm:flex-row">
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}

View File

@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
return (
<Button
size={'lg'}
className={'mb-1'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<Col className={'items-center sm:flex-row sm:gap-x-2'}>
<Col className={'items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-6 w-6',
'h-[24px] w-5 sm:mr-2',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))

View File

@ -0,0 +1,98 @@
import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { linkClass, SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { useState } from 'react'
export function ProbChangeTable(props: { userId: string | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
const [expanded, setExpanded] = useState(false)
if (!changes) {
return null
}
const count = expanded ? 16 : 4
const { positiveChanges, negativeChanges } = changes
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
const filteredChanges = [
...filteredPositiveChanges,
...filteredNegativeChanges,
]
return (
<Col>
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="flex-1 divide-y">
{filteredChanges.slice(0, count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<Col className="flex-1 divide-y">
{filteredChanges.slice(count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
</Col>
<div
className={clsx(linkClass, 'cursor-pointer self-end')}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : 'Show more'}
</div>
</Col>
)
}
export function ProbChange(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
const {
probChanges: { day: change },
} = contract
const color =
change > 0
? 'text-green-500'
: change < 0
? 'text-red-500'
: 'text-gray-600'
const str =
change === 0
? '+0%'
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
return <div className={clsx(className, color)}>{str}</div>
}

View File

@ -1,13 +1,13 @@
import React from 'react'
import Link from 'next/link'
import { Button } from './button'
import { SiteLink } from 'web/components/site-link'
export const CreateQuestionButton = () => {
return (
<Link href="/create" passHref>
<Button color="gradient" size="xl" className="mt-4">
<SiteLink href="/create">
<Button color="gradient" size="xl" className="mt-4 w-full">
Create a market
</Button>
</Link>
</SiteLink>
)
}

View File

@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
export function DoubleCarousel(props: {
contracts: Contract[]
seeMoreUrl?: string
showTime?: ShowTime
loadMore?: () => void
}) {
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
? range(0, Math.floor(contracts.length / 2)).map((col) => {
const i = col * 2
return (
<Col key={contracts[i].id}>
<Col className="snap-start scroll-m-4" key={contracts[i].id}>
<ContractCard
contract={contracts[i]}
className="mb-2 w-96 shrink-0"

View File

@ -254,7 +254,7 @@ export function RichContent(props: {
extensions: [
StarterKit,
smallImage ? DisplayImage : Image,
DisplayLink,
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention,
Iframe,
TiptapTweet,

View File

@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments'
import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
return (
<>
<CommentInput
<ContractCommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}

View File

@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import {
CommentInput,
ContractCommentInput,
FeedComment,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
<ContractCommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}

View File

@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users'
import {
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { createCommentOnContract } from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, TextEditor, useTextEditor } from '../editor'
import { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: {
user: User | null | undefined
@ -90,14 +86,16 @@ export function FeedCommentThread(props: {
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
<ContractCommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
}}
/>
</Col>
)}
@ -125,15 +123,12 @@ export function FeedComment(props: {
} = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
let betOutcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
betOutcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const [highlighted, setHighlighted] = useState(false)
@ -148,7 +143,7 @@ export function FeedComment(props: {
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
comment.betId ? [] : betsBySameUser
)
return (
@ -175,7 +170,7 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{!matchedBet &&
{!comment.betId != null &&
userPosition > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
@ -194,7 +189,6 @@ export function FeedComment(props: {
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract}
truncate="short"
/>
@ -271,67 +265,76 @@ function CommentStatus(props: {
)
}
//TODO: move commentinput and comment input text area into their own files
export function CommentInput(props: {
export function ContractCommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: () => void
}) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(betId: string | undefined) {
async function onSubmitComment(editor: Editor, betId: string | undefined) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract(
contract.id,
props.contract.id,
editor.getJSON(),
user,
betId,
parentAnswerOutcome,
parentCommentId
props.parentAnswerOutcome,
props.parentCommentId
)
onSubmitComment?.()
setIsSubmitting(false)
props.onSubmitComment?.()
}
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
className={props.className}
mostRecentCommentableBet={mostRecentCommentableBet}
/>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
</Col>
)
}
function CommentBetArea(props: {
betsByCurrentUser: Bet[]
contract: Contract
commentsByCurrentUser: ContractComment[]
parentAnswerOutcome?: string
user?: User | null
className?: string
mostRecentCommentableBet?: Bet
}) {
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
Date.now(),
@ -340,158 +343,36 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
</>
)}
</div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
/>
</>
)}
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}
function getBettorsLargestPositionBeforeTime(
contract: Contract,
createdTime: number,

View File

@ -5,6 +5,7 @@ import {
CheckIcon,
PlusCircleIcon,
SelectorIcon,
UserIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button'
@ -12,6 +13,7 @@ import { useState } from 'react'
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
import { Row } from 'web/components/layout/row'
export function GroupSelector(props: {
selectedGroup: Group | undefined
@ -28,13 +30,27 @@ export function GroupSelector(props: {
const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('')
const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups
.concat(
(useMemberGroups(creator?.id) ?? []).filter(
(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)
)
@ -96,7 +112,7 @@ export function GroupSelector(props: {
value={group}
className={({ active }) =>
clsx(
'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9',
'relative h-12 cursor-pointer select-none py-2 pr-6',
active ? 'bg-indigo-500 text-white' : 'text-gray-900'
)
}
@ -115,11 +131,28 @@ export function GroupSelector(props: {
)}
<span
className={clsx(
'ml-5 mt-1 block truncate',
'ml-3 mt-1 block flex flex-row justify-between',
selected && 'font-semibold'
)}
>
{group.name}
<Row className={'items-center gap-1 truncate pl-5'}>
{memberGroupIds.includes(group.id) && (
<UserIcon
className={'text-primary h-4 w-4 shrink-0'}
/>
)}
{group.name}
</Row>
<span
className={clsx(
'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white',
group.totalContracts > 99 ? 'w-[2.1rem]' : ''
)}
>
{group.totalContracts > 99
? '99+'
: group.totalContracts}
</span>
</span>
</>
)}

View File

@ -107,7 +107,7 @@ export function JoinOrLeaveGroupButton(props: {
onClick={firebaseLogin}
className={clsx('btn btn-sm', small && smallStyle, className)}
>
Login to Join
Login to follow
</button>
)
}
@ -132,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: {
)}
onClick={withTracking(onLeaveGroup, 'leave group')}
>
Leave
Unfollow
</button>
)
}
@ -144,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: {
className={clsx('btn btn-sm', small && smallStyle, className)}
onClick={withTracking(onJoinGroup, 'join group')}
>
Join
Follow
</button>
)
}

View File

@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">
Predict{' '}
A{' '}
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
anything!
</span>
market
</span>{' '}
for every question
</div>
</h1>
<Spacer h={6} />
<div className="mb-4 px-2 ">
Create a play-money prediction market on any topic you care about
and bet with your friends on what will happen!
Create a play-money prediction market on any topic you care about.
Trade with your friends to forecast the future.
<br />
{/* <br />
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
<SiteLink className="font-semibold" href="/charity">
favorite charity.
</SiteLink>
<br /> */}
</div>
</div>
<Spacer h={6} />

View File

@ -64,7 +64,7 @@ export function BottomNavBar() {
item={{
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=bets`,
href: `/${user.username}?tab=trades`,
icon: () => (
<Avatar
className="mx-auto my-1"

View File

@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) {
const { user } = props
return (
<Link href={`/${user.username}?tab=bets`}>
<Link href={`/${user.username}?tab=trades`}>
<a
onClick={trackCallback('sidebar: profile')}
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}

View File

@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => (
<InfoBox
title="Play-money betting"
title="Play-money trading"
className="mt-4 max-w-md"
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!"
text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/>
)

View File

@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit'
height?: number
includeTime?: boolean
}) {
const { portfolioHistory, height, includeTime } = props
const { portfolioHistory, height, includeTime, mode } = props
const { width } = useWindowSize()
const points = portfolioHistory.map((p) => {
const { timestamp, balance, investmentValue, totalDeposits } = p
const value = balance + investmentValue
const profit = value - totalDeposits
return {
x: new Date(p.timestamp),
y: p.balance + p.investmentValue,
x: new Date(timestamp),
y: mode === 'value' ? value : profit,
}
})
const data = [{ id: 'Value', data: points, color: '#11b981' }]

View File

@ -5,6 +5,7 @@ import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo(
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
return <></>
}
const { balance, investmentValue } = lastPortfolioMetrics
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
const totalValue = balance + investmentValue
const totalProfit = totalValue - totalDeposits
return (
<>
<Row className="gap-8">
<Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">{formatMoney(totalValue)}</div>
<div className="text-sm text-gray-500">Profit</div>
<div className="text-lg">{formatMoney(totalProfit)}</div>
</Col>
<select
className="select select-bordered self-start"
@ -42,6 +44,7 @@ export const PortfolioValueSection = memo(
}}
>
<option value="allTime">All time</option>
<option value="monthly">Last Month</option>
<option value="weekly">Last 7d</option>
<option value="daily">Last 24h</option>
</select>
@ -49,6 +52,17 @@ export const PortfolioValueSection = memo(
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
mode="profit"
/>
<Spacer h={8} />
<Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">{formatMoney(totalValue)}</div>
</Col>
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
mode="value"
/>
</>
)

View File

@ -11,7 +11,7 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span>
<span className="text-xl">Daily loans on your trades</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>

View File

@ -83,14 +83,14 @@ export function ResolutionPanel(props: {
<div>
{outcome === 'YES' ? (
<>
Winnings will be paid out to YES bettors.
Winnings will be paid out to traders who bought YES.
{/* <br />
<br />
You will earn {earnedFees}. */}
</>
) : outcome === 'NO' ? (
<>
Winnings will be paid out to NO bettors.
Winnings will be paid out to traders who bought NO.
{/* <br />
<br />
You will earn {earnedFees}. */}

View File

@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
size={size}
color="gradient"
>
{label ?? 'Sign up to bet!'}
{label ?? 'Sign up to predict!'}
</Button>
) : null
}

View File

@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
comment.commentType === 'contract' ? comment.contractId : undefined
const groupId =
comment.commentType === 'group' ? comment.groupId : undefined
const postId = comment.commentType === 'post' ? comment.postId : undefined
await transact({
amount: change,
fromId: user.id,
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER',
token: 'M$',
category: 'TIP',
data: { commentId: comment.id, contractId, groupId },
data: { commentId: comment.id, contractId, groupId, postId },
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
})
@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
commentId: comment.id,
contractId,
groupId,
postId,
amount: change,
fromId: user.id,
toId: comment.userId,

View File

@ -168,62 +168,63 @@ export function UserPage(props: { user: User }) {
<Spacer h={4} />
</>
)}
<Row className="flex-wrap items-center gap-2 sm:gap-4">
{user.website && (
<SiteLink
href={
'https://' +
user.website.replace('http://', '').replace('https://', '')
}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
</Row>
</SiteLink>
)}
{(user.website || user.twitterHandle || user.discordHandle) && (
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4">
{user.website && (
<SiteLink
href={
'https://' +
user.website.replace('http://', '').replace('https://', '')
}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
</Row>
</SiteLink>
)}
{user.twitterHandle && (
<SiteLink
href={`https://twitter.com/${user.twitterHandle
.replace('https://www.twitter.com/', '')
.replace('https://twitter.com/', '')
.replace('www.twitter.com/', '')
.replace('twitter.com/', '')}`}
>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"
className="h-4 w-4"
alt="Twitter"
/>
<span className="text-sm text-gray-500">
{user.twitterHandle}
</span>
</Row>
</SiteLink>
)}
{user.twitterHandle && (
<SiteLink
href={`https://twitter.com/${user.twitterHandle
.replace('https://www.twitter.com/', '')
.replace('https://twitter.com/', '')
.replace('www.twitter.com/', '')
.replace('twitter.com/', '')}`}
>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"
className="h-4 w-4"
alt="Twitter"
/>
<span className="text-sm text-gray-500">
{user.twitterHandle}
</span>
</Row>
</SiteLink>
)}
{user.discordHandle && (
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
<Row className="items-center gap-1">
<img
src="/discord-logo.svg"
className="h-4 w-4"
alt="Discord"
/>
<span className="text-sm text-gray-500">
{user.discordHandle}
</span>
</Row>
</SiteLink>
)}
</Row>
<Spacer h={5} />
{user.discordHandle && (
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
<Row className="items-center gap-1">
<img
src="/discord-logo.svg"
className="h-4 w-4"
alt="Discord"
/>
<span className="text-sm text-gray-500">
{user.discordHandle}
</span>
</Row>
</SiteLink>
)}
</Row>
)}
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
<Row
className={
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
}
>
<span>
@ -240,7 +241,6 @@ export function UserPage(props: { user: User }) {
/>
</Row>
)}
<Spacer h={5} />
<QueryUncontrolledTabs
currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '}
@ -255,24 +255,31 @@ export function UserPage(props: { user: User }) {
title: 'Comments',
content: (
<Col>
<Row className={'mt-2 mb-4 flex-wrap items-center gap-6'}>
<UserCommentsList user={user} />
</Col>
),
},
{
title: 'Trades',
content: (
<>
<BetsList user={user} />
</>
),
},
{
title: 'Stats',
content: (
<Col className="mb-8">
<Row className={'mb-8 flex-wrap items-center gap-6'}>
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} />
<GroupsButton user={user} />
<UserLikesButton user={user} />
</Row>
<UserCommentsList user={user} />
</Col>
),
},
{
title: 'Bets',
content: (
<>
<PortfolioValueSection userId={user.id} />
<BetsList user={user} />
</>
</Col>
),
},
]}

View File

@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
)}
onClick={onClick}
>
Bet
Buy
</button>
)
}

View File

@ -1,8 +1,14 @@
import { useEffect, useState } from 'react'
import { Comment, ContractComment, GroupComment } from 'common/comment'
import {
Comment,
ContractComment,
GroupComment,
PostComment,
} from 'common/comment'
import {
listenForCommentsOnContract,
listenForCommentsOnGroup,
listenForCommentsOnPost,
listenForRecentComments,
} from 'web/lib/firebase/comments'
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
return comments
}
export const useCommentsOnPost = (postId: string | undefined) => {
const [comments, setComments] = useState<PostComment[] | undefined>()
useEffect(() => {
if (postId) return listenForCommentsOnPost(postId, setComments)
}, [postId])
return comments
}
export const useRecentComments = () => {
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
useEffect(() => listenForRecentComments(setRecentComments), [])

View File

@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getMemberGroups,
GroupMemberDoc,
groupMembers,
listenForGroup,
listenForGroupContractDocs,
listenForGroups,
listenForMemberGroupIds,
listenForMemberGroups,
listenForOpenGroups,
listGroups,
} from 'web/lib/firebase/groups'
@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
import { uniq } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils'
import { useQuery } from 'react-query'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -49,12 +50,10 @@ export const useOpenGroups = () => {
}
export const useMemberGroups = (userId: string | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (userId)
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
}, [userId])
return memberGroups
const result = useQuery(['member-groups', userId ?? ''], () =>
getMemberGroups(userId ?? '')
)
return result.data
}
// Note: We cache member group ids in localstorage to speed up the initial load

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
function inIframe() {
export function inIframe() {
try {
return window.self !== window.top
} catch (e) {

View File

@ -103,6 +103,7 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => {
isEnd: state.isComplete && state.pageEnd >= state.docs.length,
getPrev: () => dispatch({ type: 'PREV' }),
getNext: () => dispatch({ type: 'NEXT' }),
allItems: () => state.docs.map((d) => d.data()),
getItems: () =>
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()),
}

View File

@ -0,0 +1,22 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import {
getProbChangesNegative,
getProbChangesPositive,
} from 'web/lib/firebase/contracts'
export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData(
['prob-changes-day-positive', userId],
getProbChangesPositive(userId)
)
const { data: negativeChanges } = useFirestoreQueryData(
['prob-changes-day-negative', userId],
getProbChangesNegative(userId)
)
if (!positiveChanges || !negativeChanges) {
return undefined
}
return { positiveChanges, negativeChanges }
}

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import {
listenForTipTxns,
listenForTipTxnsOnGroup,
listenForTipTxnsOnPost,
} from 'web/lib/firebase/txns'
export type CommentTips = { [userId: string]: number }
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
export function useTipTxns(on: {
contractId?: string
groupId?: string
postId?: string
}): CommentTipMap {
const [txns, setTxns] = useState<TipTxn[]>([])
const { contractId, groupId } = on
const { contractId, groupId, postId } = on
useEffect(() => {
if (contractId) return listenForTipTxns(contractId, setTxns)
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
}, [contractId, groupId, setTxns])
if (postId) return listenForTipTxnsOnPost(postId, setTxns)
}, [contractId, groupId, postId, setTxns])
return useMemo(() => {
const byComment = groupBy(txns, 'data.commentId')

View File

@ -1,8 +1,14 @@
import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react'
import { inIframe } from './use-is-iframe'
export const useTracking = (eventName: string, eventProperties?: any) => {
export const useTracking = (
eventName: string,
eventProperties?: any,
excludeIframe?: boolean
) => {
useEffect(() => {
if (excludeIframe && inIframe()) return
track(eventName, eventProperties)
}, [])
}

View File

@ -7,12 +7,22 @@ import {
query,
setDoc,
where,
DocumentData,
DocumentReference,
} from 'firebase/firestore'
import { getValues, listenForValues } from './utils'
import { db } from './init'
import { User } from 'common/user'
import { Comment, ContractComment, GroupComment } from 'common/comment'
import {
Comment,
ContractComment,
GroupComment,
OnContract,
OnGroup,
OnPost,
PostComment,
} from 'common/comment'
import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react'
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createCommentOnContract(
contractId: string,
content: JSONContent,
commenter: User,
user: User,
betId?: string,
answerOutcome?: string,
replyToCommentId?: string
@ -32,28 +42,20 @@ export async function createCommentOnContract(
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
// contract slug and question are set via trigger
const comment = removeUndefinedProps({
id: ref.id,
const onContract = {
commentType: 'contract',
contractId,
userId: commenter.id,
content: content,
createdTime: Date.now(),
userName: commenter.name,
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
betId: betId,
answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId,
})
track('comment', {
betId,
answerOutcome,
} as OnContract
return await createComment(
contractId,
commentId: ref.id,
betId: betId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
onContract,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnGroup(
groupId: string,
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
replyToCommentId?: string
) {
const ref = doc(getCommentsOnGroupCollection(groupId))
const onGroup = { commentType: 'group', groupId: groupId } as OnGroup
return await createComment(
groupId,
onGroup,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnPost(
postId: string,
content: JSONContent,
user: User,
replyToCommentId?: string
) {
const ref = doc(getCommentsOnPostCollection(postId))
const onPost = { postId: postId, commentType: 'post' } as OnPost
return await createComment(
postId,
onPost,
content,
user,
ref,
replyToCommentId
)
}
async function createComment(
surfaceId: string,
extraFields: OnContract | OnGroup | OnPost,
content: JSONContent,
user: User,
ref: DocumentReference<DocumentData>,
replyToCommentId?: string
) {
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'group',
groupId,
userId: user.id,
content: content,
createdTime: Date.now(),
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
userUsername: user.username,
userAvatarUrl: user.avatarUrl,
replyToCommentId: replyToCommentId,
...extraFields,
})
track('group message', {
track(`${extraFields.commentType} message`, {
user,
commentId: ref.id,
groupId,
surfaceId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments')
}
function getCommentsOnPostCollection(postId: string) {
return collection(db, 'posts', postId, 'comments')
}
export async function listAllComments(contractId: string) {
return await getValues<Comment>(
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) {
)
}
export async function listAllCommentsOnPost(postId: string) {
return await getValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc'))
)
}
export function listenForCommentsOnContract(
contractId: string,
setComments: (comments: ContractComment[]) => void
@ -126,6 +175,16 @@ export function listenForCommentsOnGroup(
)
}
export function listenForCommentsOnPost(
postId: string,
setComments: (comments: PostComment[]) => void
) {
return listenForValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')),
setComments
)
}
const DAY_IN_MS = 24 * 60 * 60 * 1000
// Define "recent" as "<3 days ago" for now

View File

@ -16,7 +16,7 @@ import {
import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract'
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
import { createRNG, shuffle } from 'common/util/random'
import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
@ -104,6 +104,14 @@ export async function listContracts(creatorId: string): Promise<Contract[]> {
return snapshot.docs.map((doc) => doc.data())
}
export const tournamentContractsByGroupSlugQuery = (slug: string) =>
query(
contracts,
where('groupSlugs', 'array-contains', slug),
where('isResolved', '==', false),
orderBy('popularityScore', 'desc')
)
export async function listContractsByGroupSlug(
slug: string
): Promise<Contract[]> {
@ -395,3 +403,21 @@ export async function getRecentBetsAndComments(contract: Contract) {
recentComments,
}
}
export const getProbChangesPositive = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '>', 0),
orderBy('probChanges.day', 'desc'),
limit(10)
) as Query<CPMMContract>
export const getProbChangesNegative = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '<', 0),
orderBy('probChanges.day', 'asc'),
limit(10)
) as Query<CPMMContract>

View File

@ -11,7 +11,7 @@ import {
updateDoc,
where,
} from 'firebase/firestore'
import { uniq } from 'lodash'
import { uniq, uniqBy } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import {
coll,
@ -21,7 +21,7 @@ import {
listenForValues,
} from './utils'
import { Contract } from 'common/contract'
import { updateContract } from 'web/lib/firebase/contracts'
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
import { db } from 'web/lib/firebase/init'
import { filterDefined } from 'common/util/array'
import { getUser } from 'web/lib/firebase/users'
@ -31,6 +31,9 @@ export const groupMembers = (groupId: string) =>
collection(groups, groupId, 'groupMembers')
export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts')
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
export const memberGroupsQuery = (userId: string) =>
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
export function groupPath(
groupSlug: string,
@ -78,23 +81,25 @@ export function listenForGroupContractDocs(
return listenForValues(groupContracts(groupId), setContractDocs)
}
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(
query(groups, where('anyoneCanJoin', '==', true)),
setGroups
export async function listGroupContracts(groupId: string) {
const contractDocs = await getValues<{
contractId: string
createdTime: number
}>(groupContracts(groupId))
const contracts = await Promise.all(
contractDocs.map((doc) => getContractFromId(doc.contractId))
)
return filterDefined(contracts)
}
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(openGroupsQuery, setGroups)
}
export function getGroup(groupId: string) {
return getValue<Group>(doc(groups, groupId))
}
export function getGroupContracts(groupId: string) {
return getValues<{ contractId: string; createdTime: number }>(
groupContracts(groupId)
)
}
export async function getGroupBySlug(slug: string) {
const q = query(groups, where('slug', '==', slug))
const docs = (await getDocs(q)).docs
@ -108,14 +113,20 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup)
}
export async function getMemberGroups(userId: string) {
const snapshot = await getDocs(memberGroupsQuery(userId))
const groupIds = filterDefined(
snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = await Promise.all(groupIds.map(getGroup))
return filterDefined(groups)
}
export function listenForMemberGroupIds(
userId: string,
setGroupIds: (groupIds: string[]) => void
) {
const q = query(
collectionGroup(db, 'groupMembers'),
where('userId', '==', userId)
)
const q = memberGroupsQuery(userId)
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return
@ -136,6 +147,24 @@ export function listenForMemberGroups(
})
}
export async function listAvailableGroups(userId: string) {
const [openGroups, memberGroupSnapshot] = await Promise.all([
getValues<Group>(openGroupsQuery),
getDocs(memberGroupsQuery(userId)),
])
const memberGroups = filterDefined(
await Promise.all(
memberGroupSnapshot.docs.map((doc) => {
return doc.ref.parent.parent?.id
? getGroup(doc.ref.parent.parent?.id)
: null
})
)
)
return uniqBy([...openGroups, ...memberGroups], (g) => g.id)
}
export async function addUserToGroupViaId(groupId: string, userId: string) {
// get group to get the member ids
const group = await getGroup(groupId)

View File

@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
where('data.groupId', '==', groupId)
)
const getTipsOnPostQuery = (postId: string) =>
query(
txns,
where('category', '==', 'TIP'),
where('data.postId', '==', postId)
)
export function listenForTipTxns(
contractId: string,
setTxns: (txns: TipTxn[]) => void
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
}
export function listenForTipTxnsOnPost(
postId: string,
setTxns: (txns: TipTxn[]) => void
) {
return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns)
}
// Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])

View File

@ -0,0 +1,19 @@
export default function ChallengeIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={props.className}
viewBox="0 0 24 24"
>
<g>
<polygon points="18.63 15.11 15.37 18.49 3.39 6.44 1.82 1.05 7.02 2.68 18.63 15.11" />
<polygon points="21.16 13.73 22.26 14.87 19.51 17.72 23 21.35 21.41 23 17.91 19.37 15.16 22.23 14.07 21.09 21.16 13.73" />
</g>
<g>
<polygon points="8.6 18.44 5.34 15.06 16.96 2.63 22.15 1 20.58 6.39 8.6 18.44" />
<polygon points="9.93 21.07 8.84 22.21 6.09 19.35 2.59 22.98 1 21.33 4.49 17.7 1.74 14.85 2.84 13.71 9.93 21.07" />
</g>
</svg>
)
}

View File

@ -9,6 +9,9 @@ module.exports = {
reactStrictMode: true,
optimizeFonts: false,
experimental: {
images: {
allowFutureImage: true,
},
scrollRestoration: true,
externalDir: true,
modularizeImports: {
@ -25,7 +28,12 @@ module.exports = {
},
},
images: {
domains: ['lh3.googleusercontent.com', 'i.imgur.com'],
domains: [
'manifold.markets',
'lh3.googleusercontent.com',
'i.imgur.com',
'firebasestorage.googleapis.com',
],
},
async redirects() {
return [

View File

@ -69,7 +69,7 @@ export async function getStaticPropz(props: {
comments: comments.slice(0, 1000),
},
revalidate: 60, // regenerate after a minute
revalidate: 5, // regenerate after five seconds
}
}
@ -158,11 +158,15 @@ export function ContractPageContent(
const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id)
useTracking('view market', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
useTracking(
'view market',
{
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
},
true
)
const bets = useBets(contract.id) ?? props.bets
const nonChallengeBets = useMemo(

View File

@ -0,0 +1,21 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { listGroupContracts } from 'web/lib/firebase/groups'
import { toLiteMarket } from 'web/pages/api/v0/_types'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { id } = req.query
const contracts = (await listGroupContracts(id as string)).map((contract) =>
toLiteMarket(contract)
)
if (!contracts) {
res.status(404).json({ error: 'Group not found' })
return
}
res.setHeader('Cache-Control', 'no-cache')
return res.status(200).json(contracts)
}

View File

@ -1,14 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { listAllGroups } from 'web/lib/firebase/groups'
import { listAllGroups, listAvailableGroups } from 'web/lib/firebase/groups'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { z } from 'zod'
import { validate } from 'web/pages/api/v0/_validate'
import { ValidationError } from 'web/pages/api/v0/_types'
type Data = any[]
const queryParams = z
.object({
availableToUserId: z.string().optional(),
})
.strict()
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
let params: z.infer<typeof queryParams>
try {
params = validate(queryParams, req.query)
} catch (e) {
if (e instanceof ValidationError) {
return res.status(400).json(e)
}
console.error(`Unknown error during validation: ${e}`)
return res.status(500).json({ error: 'Unknown error during validation' })
}
const { availableToUserId } = params
// TODO: should we check if the user is a real user?
if (availableToUserId) {
const groups = await listAvailableGroups(availableToUserId)
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups)
return
}
const groups = await listAllGroups()
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups)

View File

@ -20,7 +20,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { getGroup } from 'web/lib/firebase/groups'
import { getGroup, groupPath } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -34,6 +34,8 @@ import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
import { MINUTE_MS } from 'common/util/time'
import { ExternalLinkIcon } from '@heroicons/react/outline'
import { SiteLink } from 'web/components/site-link'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -290,9 +292,9 @@ export function NewContract(props: {
}}
choicesMap={{
'Yes / No': 'BINARY',
'Multiple choice': 'MULTIPLE_CHOICE',
// 'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC',
// Numeric: 'PSEUDO_NUMERIC',
}}
isSubmitting={isSubmitting}
className={'col-span-4'}
@ -406,13 +408,19 @@ export function NewContract(props: {
<Spacer h={6} />
<GroupSelector
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
options={{ showSelector: showGroupSelector, showLabel: true }}
/>
<Row className={'items-end gap-x-2'}>
<GroupSelector
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
options={{ showSelector: showGroupSelector, showLabel: true }}
/>
{showGroupSelector && selectedGroup && (
<SiteLink href={groupPath(selectedGroup.slug)}>
<ExternalLinkIcon className=" ml-1 mb-3 h-5 w-5 text-gray-500" />
</SiteLink>
)}
</Row>
<Spacer h={6} />
<div className="form-control mb-1 items-start">
@ -483,17 +491,17 @@ export function NewContract(props: {
{formatMoney(ante)}
</div>
) : (
<div>
<div className="label-text text-primary pl-1">
FREE{' '}
<span className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX -
(creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</span>
<Row>
<div className="label-text text-neutral pl-1 line-through">
{formatMoney(ante)}
</div>
</div>
<div className="label-text text-primary pl-1">FREE </div>
<div className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX - (creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</div>
</Row>
)}
{ante > balance && !deservesFreeMarket && (

View File

@ -21,6 +21,7 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
import { listAllBets } from 'web/lib/firebase/bets'
import {
contractPath,
@ -82,6 +83,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const { question, outcomeType } = contract
useTracking('view market embed', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'

View File

@ -0,0 +1,60 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ArrangeHome } from 'web/components/arrange-home'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
export default function Home() {
const user = useUser()
useTracking('edit home')
const [homeSections, setHomeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}>
<Title text="Edit your home page" />
<DoneButton />
</Row>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</Col>
</Page>
)
}
function DoneButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home">
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
Done
</Button>
</SiteLink>
)
}

View File

@ -1,39 +1,36 @@
import React, { useState } from 'react'
import Router from 'next/router'
import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid'
import {
PencilIcon,
PlusSmIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { GroupLinkItem } from '../../groups'
import { SiteLink } from 'web/components/site-link'
import { useUser } from 'web/hooks/use-user'
import { useMemberGroups } from 'web/hooks/use-group'
import { DoubleCarousel } from '../../../components/double-carousel'
import clsx from 'clsx'
import { Button } from 'web/components/button'
import { ArrangeHome, getHomeItems } from '../../../components/arrange-home'
import { getHomeItems } from '../../../components/arrange-home'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { formatMoney } from 'common/util/format'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = useUser() ?? props.auth?.user ?? null
const Home = () => {
const user = useUser()
useTracking('view home')
@ -41,71 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => {
const groups = useMemberGroups(user?.id) ?? []
const [homeSections, setHomeSections] = useState(
const [homeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const { visibleItems } = getHomeItems(groups, homeSections)
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
const [isEditing, setIsEditing] = useState(false)
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]">
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}>
<Title text={isEditing ? 'Edit your home page' : 'Home'} />
<Title className="!mb-0" text="Home" />
<EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} />
<EditButton />
</Row>
{isEditing ? (
<>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</>
) : (
visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your bets'}
sort={'newest'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
<DailyProfitAndBalance userId={user?.id} />
const group = groups.find((g) => g.id === id)
if (group)
return <GroupSection key={id} group={group} user={user} />
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
return null
})
)}
{visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your trades'}
sort={'newest'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
return null
})}
</Col>
<button
type="button"
@ -123,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => {
function SearchSection(props: {
label: string
user: User | null
user: User | null | undefined
sort: Sort
yourBets?: boolean
}) {
@ -133,88 +113,91 @@ function SearchSection(props: {
return (
<Col>
<SiteLink className="mb-2 text-xl" href={href}>
{label}
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ContractSearch
user={user}
defaultSort={sort}
additionalFilter={yourBets ? { yourBets: true } : undefined}
additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
noControls
// persistPrefix={`experimental-home-${sort}`}
renderContracts={(contracts, loadMore) =>
contracts ? (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={href}
showTime={
sort === 'close-date' || sort === 'resolve-date'
? sort
: undefined
}
loadMore={loadMore}
/>
) : (
<LoadingIndicator />
)
}
maxResults={6}
persistPrefix={`experimental-home-${sort}`}
/>
</Col>
)
}
function GroupSection(props: { group: Group; user: User | null }) {
function GroupSection(props: { group: Group; user: User | null | undefined }) {
const { group, user } = props
return (
<Col>
<GroupLinkItem className="mb-2 text-xl" group={group} />
<SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
{group.name}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
noControls
// persistPrefix={`experimental-home-${group.slug}`}
renderContracts={(contracts, loadMore) =>
contracts ? (
contracts.length == 0 ? (
<div className="m-2 text-gray-500">No open markets</div>
) : (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={`/group/${group.slug}`}
loadMore={loadMore}
/>
)
) : (
<LoadingIndicator />
)
}
maxResults={6}
persistPrefix={`experimental-home-${group.slug}`}
/>
</Col>
)
}
function EditDoneButton(props: {
isEditing: boolean
setIsEditing: (isEditing: boolean) => void
className?: string
}) {
const { isEditing, setIsEditing, className } = props
function EditButton(props: { className?: string }) {
const { className } = props
return (
<Button
size="lg"
color={isEditing ? 'blue' : 'gray-white'}
className={clsx(className, 'flex')}
onClick={() => {
setIsEditing(!isEditing)
}}
>
{!isEditing && (
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
)}
{isEditing ? 'Done' : 'Edit'}
</Button>
<SiteLink href="/experimental/home/edit">
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
Edit
</Button>
</SiteLink>
)
}
function DailyProfitAndBalance(props: {
userId: string | null | undefined
className?: string
}) {
const { userId, className } = props
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
if (first === undefined || last === undefined) return null
const profit =
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
const balanceChange = last.balance - first.balance
return (
<div className={clsx(className, 'text-lg')}>
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
{profit >= 0 ? '+' : '-'}
{formatMoney(profit)}
</span>{' '}
profit and{' '}
<span
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
>
{balanceChange >= 0 ? '+' : '-'}
{formatMoney(balanceChange)}
</span>{' '}
balance today
</div>
)
}

View File

@ -52,6 +52,7 @@ import { Post } from 'common/post'
import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -226,6 +227,7 @@ export default function GroupPage(props: {
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
)
@ -659,22 +661,25 @@ function JoinGroupButton(props: {
user: User | null | undefined
}) {
const { group, user } = props
function addUserToGroup() {
if (user) {
toast.promise(joinGroup(group, user.id), {
loading: 'Joining group...',
success: 'Joined group!',
error: "Couldn't join group, try again?",
})
}
const follow = async () => {
track('join group')
const userId = user ? user.id : (await firebaseLogin()).user.uid
toast.promise(joinGroup(group, userId), {
loading: 'Following group...',
success: 'Followed',
error: "Couldn't follow group, try again?",
})
}
return (
<div>
<button
onClick={user ? addUserToGroup : firebaseLogin}
onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
>
{user ? 'Join group' : 'Login to join group'}
Follow
</button>
</div>
)

View File

@ -65,20 +65,9 @@ export default function Groups(props: {
const [query, setQuery] = useState('')
// List groups with the highest question count, then highest member count
// TODO use find-active-contracts to sort by?
const matches = sortBy(groups, []).filter((g) =>
searchInAny(
query,
g.name,
g.about || '',
creatorsDict[g.creatorId].username
)
)
const matchesOrderedByRecentActivity = sortBy(groups, [
(group) =>
-1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime),
const matchesOrderedByMostContractAndMembers = sortBy(groups, [
(group) => -1 * group.totalContracts,
(group) => -1 * group.totalMembers,
]).filter((g) =>
searchInAny(
query,
@ -120,13 +109,14 @@ export default function Groups(props: {
<Col>
<input
type="text"
value={query}
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups"
className="input input-bordered mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
{matchesOrderedByRecentActivity
{matchesOrderedByMostContractAndMembers
.filter((match) =>
memberGroupIds.includes(match.id)
)
@ -153,11 +143,12 @@ export default function Groups(props: {
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
value={query}
className="input input-bordered mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
{matches.map((group) => (
{matchesOrderedByMostContractAndMembers.map((group) => (
<GroupCard
key={group.id}
group={group}

View File

@ -135,21 +135,20 @@ export default function Leaderboards(_props: {
defaultIndex={1}
tabs={[
{
title: 'All Time',
content: LeaderboardWithPeriod('allTime'),
title: 'Daily',
content: LeaderboardWithPeriod('daily'),
},
// TODO: Enable this near the end of July!
// {
// title: 'Monthly',
// content: LeaderboardWithPeriod('monthly'),
// },
{
title: 'Weekly',
content: LeaderboardWithPeriod('weekly'),
},
{
title: 'Daily',
content: LeaderboardWithPeriod('daily'),
title: 'Monthly',
content: LeaderboardWithPeriod('monthly'),
},
{
title: 'All Time',
content: LeaderboardWithPeriod('allTime'),
},
]}
/>

View File

@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} unique traders on`
} new traders on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
@ -508,7 +508,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'}
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/>
)}
<Row className={'line-clamp-2 flex max-w-xl'}>

View File

@ -1,12 +1,12 @@
import { Page } from 'web/components/page'
import { postPath, getPostBySlug } from 'web/lib/firebase/posts'
import { postPath, getPostBySlug, updatePost } from 'web/lib/firebase/posts'
import { Post } from 'common/post'
import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer'
import { Content } from 'web/components/editor'
import { Content, TextEditor, useTextEditor } from 'web/components/editor'
import { getUser, User } from 'web/lib/firebase/users'
import { ShareIcon } from '@heroicons/react/solid'
import { PencilIcon, ShareIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { Button } from 'web/components/button'
import { useState } from 'react'
@ -16,17 +16,27 @@ import { Col } from 'web/components/layout/col'
import { ENV_CONFIG } from 'common/envs/constants'
import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-link'
import { listAllCommentsOnPost } from 'web/lib/firebase/comments'
import { PostComment } from 'common/comment'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash'
import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments'
import { useCommentsOnPost } from 'web/hooks/use-comments'
import { useUser } from 'web/hooks/use-user'
import { usePost } from 'web/hooks/use-post'
export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
const post = await getPostBySlug(slugs[0])
const creator = post ? await getUser(post.creatorId) : null
const comments = post && (await listAllCommentsOnPost(post.id))
return {
props: {
post: post,
creator: creator,
comments: comments,
},
revalidate: 60, // regenerate after a minute
@ -37,28 +47,38 @@ export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
export default function PostPage(props: { post: Post; creator: User }) {
export default function PostPage(props: {
post: Post
creator: User
comments: PostComment[]
}) {
const [isShareOpen, setShareOpen] = useState(false)
const { creator } = props
const post = usePost(props.post.id) ?? props.post
if (props.post == null) {
const tips = useTipTxns({ postId: post.id })
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}`
const updatedComments = useCommentsOnPost(post.id)
const comments = updatedComments ?? props.comments
const user = useUser()
if (post == null) {
return <Custom404 />
}
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
return (
<Page>
<div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} />
<Title className="!mt-0" text={props.post.title} />
<Title className="!mt-0" text={post.title} />
<Row>
<Col className="flex-1">
<div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={props.creator.name}
username={props.creator.username}
name={creator.name}
username={creator.username}
/>
</div>
</Col>
@ -88,10 +108,121 @@ export default function PostPage(props: { post: Post; creator: User }) {
<Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<div className="form-control w-full py-2">
<Content content={props.post.content} />
{user && user.id === post.creatorId ? (
<RichEditPost post={post} />
) : (
<Content content={post.content} />
)}
</div>
</div>
<Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity
post={post}
comments={comments}
tips={tips}
user={creator}
/>
</div>
</div>
</Page>
)
}
export function PostCommentsActivity(props: {
post: Post
comments: PostComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { post, comments, user, tips } = props
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<PostCommentInput post={post} />
{topLevelComments.map((parent) => (
<PostCommentThread
key={parent.id}
user={user}
post={post}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}
function RichEditPost(props: { post: Post }) {
const { post } = props
const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
defaultValue: post.content,
disabled: isSubmitting,
})
async function savePost() {
if (!editor) return
await updatePost(post, {
content: editor.getJSON(),
})
}
return editing ? (
<>
<TextEditor editor={editor} upload={upload} />
<Spacer h={2} />
<Row className="gap-2">
<Button
onClick={async () => {
setIsSubmitting(true)
await savePost()
setEditing(false)
setIsSubmitting(false)
}}
>
Save
</Button>
<Button color="gray" onClick={() => setEditing(false)}>
Cancel
</Button>
</Row>
</>
) : (
<>
<div className="relative">
<div className="absolute top-0 right-0 z-10 space-x-2">
<Button
color="gray"
size="xs"
onClick={() => {
setEditing(true)
editor?.commands.focus('end')
}}
>
<PencilIcon className="inline h-4 w-4" />
Edit
</Button>
</div>
<Content content={post.content} />
<Spacer h={2} />
</div>
</>
)
}

View File

@ -1,16 +1,10 @@
import { ClockIcon } from '@heroicons/react/outline'
import { UsersIcon } from '@heroicons/react/solid'
import {
BinaryContract,
Contract,
PseudoNumericContract,
} from 'common/contract'
import { Group } from 'common/group'
import dayjs, { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { keyBy, mapValues, sortBy } from 'lodash'
import { zip } from 'lodash'
import Image, { ImageProps, StaticImageData } from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import { tournamentContractsByGroupSlugQuery } from 'web/lib/firebase/contracts'
import { getGroup, groupPath } from 'web/lib/firebase/groups'
import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png'
import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png'
import mpox_pic from './_cspi/Monkeypox_Cases.png'
import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png'
import { SiteLink } from 'web/components/site-link'
import { getProbability } from 'common/calculate'
import { Carousel } from 'web/components/carousel'
import { usePagination } from 'web/hooks/use-pagination'
import { LoadingIndicator } from 'web/components/loading-indicator'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles')
const toDate = (d: string) =>
dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles').valueOf()
type MarketImage = {
marketUrl: string
image: StaticImageData
}
type Tourney = {
title: string
url?: string
blurb: string // actual description in the click-through
award?: string
endTime?: Dayjs
endTime?: number
groupId: string
}
@ -50,7 +50,7 @@ const Salem = {
url: 'https://salemcenter.manifold.markets/',
award: '$25,000',
endTime: toDate('Jul 31, 2023'),
markets: [],
contractIds: [],
images: [
{
marketUrl:
@ -107,33 +107,27 @@ const tourneys: Tourney[] = [
// },
]
export async function getStaticProps() {
const groupIds = tourneys
.map((data) => data.groupId)
.filter((id) => id != undefined) as string[]
const groups = (await Promise.all(groupIds.map(getGroup)))
// Then remove undefined groups
.filter(Boolean) as Group[]
const contracts = await Promise.all(
groups.map((g) => listContractsByGroupSlug(g?.slug ?? ''))
)
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
const groupMap = keyBy(groups, 'id')
const numPeople = mapValues(groupMap, (g) => g?.totalMembers)
const slugs = mapValues(groupMap, 'slug')
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
type SectionInfo = {
tourney: Tourney
slug: string
numPeople: number
}
export default function TournamentPage(props: {
markets: { [groupId: string]: Contract[] }
numPeople: { [groupId: string]: number }
slugs: { [groupId: string]: string }
}) {
const { markets = {}, numPeople = {}, slugs = {} } = props
export async function getStaticProps() {
const groupIds = tourneys.map((data) => data.groupId)
const groups = await Promise.all(groupIds.map(getGroup))
const sections = zip(tourneys, groups)
.filter(([_tourney, group]) => group != null)
.map(([tourney, group]) => ({
tourney,
slug: group!.slug, // eslint-disable-line
numPeople: group!.totalMembers, // eslint-disable-line
}))
return { props: { sections } }
}
export default function TournamentPage(props: { sections: SectionInfo[] }) {
const { sections } = props
return (
<Page>
@ -141,96 +135,114 @@ export default function TournamentPage(props: {
title="Tournaments"
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
/>
<Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]">
{tourneys.map(({ groupId, ...data }) => (
<Section
key={groupId}
{...data}
url={groupPath(slugs[groupId])}
ppl={numPeople[groupId] ?? 0}
markets={markets[groupId] ?? []}
/>
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
{sections.map(({ tourney, slug, numPeople }) => (
<div key={slug}>
<SectionHeader
url={groupPath(slug)}
title={tourney.title}
ppl={numPeople}
award={tourney.award}
endTime={tourney.endTime}
/>
<span>{tourney.blurb}</span>
<MarketCarousel slug={slug} />
</div>
))}
<Section {...Salem} />
<div>
<SectionHeader
url={Salem.url}
title={Salem.title}
award={Salem.award}
endTime={Salem.endTime}
/>
<span>{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} />
</div>
</Col>
</Page>
)
}
function Section(props: {
title: string
const SectionHeader = (props: {
url: string
blurb: string
award?: string
title: string
ppl?: number
endTime?: Dayjs
markets: Contract[]
images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi
}) {
const { title, url, blurb, award, ppl, endTime, images } = props
// Sort markets by probability, highest % first
const markets = sortBy(props.markets, (c) =>
getProbability(c as BinaryContract | PseudoNumericContract)
)
.reverse()
.filter((c) => !c.isResolved)
award?: string
endTime?: number
}) => {
const { url, title, ppl, award, endTime } = props
return (
<div>
<Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
{title}
</h2>
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
{!!award && <span className="flex items-center">🏆 {award}</span>}
{!!ppl && (
<Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
{title}
</h2>
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
{!!award && <span className="flex items-center">🏆 {award}</span>}
{!!ppl && (
<span className="flex items-center gap-1">
<UsersIcon className="h-4" />
{ppl}
</span>
)}
{endTime && (
<DateTimeTooltip time={endTime} text="Ends">
<span className="flex items-center gap-1">
<UsersIcon className="h-4" />
{ppl}
<ClockIcon className="h-4" />
{dayjs(endTime).format('MMM D')}
</span>
)}
{endTime && (
<DateTimeTooltip time={endTime.valueOf()} text="Ends">
<span className="flex items-center gap-1">
<ClockIcon className="h-4" />
{endTime.format('MMM D')}
</span>
</DateTimeTooltip>
)}
</Row>
</DateTimeTooltip>
)}
</Row>
</a>
</Link>
)
}
const ImageCarousel = (props: { images: MarketImage[]; url: string }) => {
const { images, url } = props
return (
<Carousel className="-mx-4 mt-4 sm:-mx-10">
<div className="shrink-0 sm:w-6" />
{images.map(({ marketUrl, image }) => (
<a key={marketUrl} href={marketUrl} className="hover:brightness-95">
<NaturalImage src={image} />
</a>
</Link>
<span>{blurb}</span>
<Carousel className="-mx-4 mt-2 sm:-mx-10">
<div className="shrink-0 sm:w-6" />
{markets.length ? (
markets.map((m) => (
<ContractCard
contract={m}
hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0"
questionClass="line-clamp-3"
trackingPostfix=" tournament"
/>
))
) : (
<>
{images?.map(({ marketUrl, image }) => (
<a href={marketUrl} className="hover:brightness-95">
<NaturalImage src={image} />
</a>
))}
<SiteLink
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
href={url}
>
See more
</SiteLink>
</>
)}
</Carousel>
</div>
))}
<SiteLink
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
href={url}
>
See more
</SiteLink>
</Carousel>
)
}
const MarketCarousel = (props: { slug: string }) => {
const { slug } = props
const q = tournamentContractsByGroupSlugQuery(slug)
const { allItems, getNext } = usePagination({ q, pageSize: 6 })
const items = allItems()
// todo: would be nice to have indicator somewhere when it loads next page
return items.length === 0 ? (
<LoadingIndicator className="mt-10" />
) : (
<Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}>
<div className="shrink-0 sm:w-6" />
{items.map((m) => (
<ContractCard
key={m.id}
contract={m}
hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
questionClass="line-clamp-3"
trackingPostfix=" tournament"
/>
))}
</Carousel>
)
}

172
web/posts/post-comments.tsx Normal file
View File

@ -0,0 +1,172 @@
import { track } from '@amplitude/analytics-browser'
import { Editor } from '@tiptap/core'
import clsx from 'clsx'
import { PostComment } from 'common/comment'
import { Post } from 'common/post'
import { User } from 'common/user'
import { Dictionary } from 'lodash'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Avatar } from 'web/components/avatar'
import { CommentInput } from 'web/components/comment-input'
import { Content } from 'web/components/editor'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Tipper } from 'web/components/tipper'
import { UserLink } from 'web/components/user-link'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { createCommentOnPost } from 'web/lib/firebase/comments'
import { firebaseLogin } from 'web/lib/firebase/users'
export function PostCommentThread(props: {
user: User | null | undefined
post: Post
threadComments: PostComment[]
tips: CommentTipMap
parentComment: PostComment
commentsByUserId: Dictionary<PostComment[]>
}) {
const { post, threadComments, tips, parentComment } = props
const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: PostComment) {
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
return (
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
{[parentComment].concat(threadComments).map((comment, commentIdx) => (
<PostComment
key={comment.id}
indent={commentIdx != 0}
post={post}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
{showReply && (
<Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<PostCommentInput
post={post}
parentCommentId={parentComment.id}
replyToUser={replyTo}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
)}
</Col>
)
}
export function PostCommentInput(props: {
post: Post
parentCommentId?: string
replyToUser?: { id: string; username: string }
onSubmitComment?: () => void
}) {
const user = useUser()
const { post, parentCommentId, replyToUser } = props
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId)
props.onSubmitComment?.()
}
return (
<CommentInput
replyToUser={replyToUser}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
/>
)
}
export function PostComment(props: {
post: Post
comment: PostComment
tips: CommentTips
indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: PostComment) => void
}) {
const { post, comment, tips, indent, onReplyClick } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [comment.id, router.asPath])
return (
<Row
id={comment.id}
className={clsx(
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
{/*draw a gray line from the comment to the left:*/}
{indent ? (
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
) : null}
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3">
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={comment.userName}
slug={post.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<Content
className="mt-2 text-[15px] text-gray-700"
content={content || text}
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</Row>
</div>
</Row>
)
}