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 { calculatePayout } from './calculate'
import { Bet } from './bet' import { Bet } from './bet'
import { Contract } from './contract' 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[]) => { export const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0) const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool( const monthlyCreatorVolume = computeTotalPool(
@ -89,12 +116,12 @@ const calculateProfitForPeriod = (
return currentProfit return currentProfit
} }
const startingProfit = calculateTotalProfit(startingPortfolio) const startingProfit = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit return currentProfit - startingProfit
} }
const calculateTotalProfit = (portfolio: PortfolioMetrics) => { export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
} }
@ -102,7 +129,7 @@ export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[], portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics newPortfolio: PortfolioMetrics
) => { ) => {
const allTimeProfit = calculateTotalProfit(newPortfolio) const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy( const descendingPortfolio = sortBy(
portfolioHistory, portfolioHistory,
(p) => p.timestamp (p) => p.timestamp

View File

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

View File

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

View File

@ -12,10 +12,6 @@ export type Group = {
aboutPostId?: string aboutPostId?: string
chatDisabled?: boolean chatDisabled?: boolean
mostRecentContractAddedTime?: number 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_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140 export const MAX_ABOUT_LENGTH = 140

View File

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

View File

@ -54,6 +54,10 @@ Returns the authenticated user.
Gets all groups, in no particular order. 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. Requires no authorization.
### `GET /v0/groups/[slug]` ### `GET /v0/groups/[slug]`
@ -62,12 +66,18 @@ Gets a group by its slug.
Requires no authorization. Requires no authorization.
### `GET /v0/groups/by-id/[id]` ### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID. Gets a group by its unique ID.
Requires no authorization. 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` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.

View File

@ -12,7 +12,9 @@ service cloud.firestore {
'taowell@gmail.com', 'taowell@gmail.com',
'abc.sinclair@gmail.com', 'abc.sinclair@gmail.com',
'manticmarkets@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() .affectedKeys()
.hasOnly(['name', 'content']); .hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; 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) .doc(comment.betId)
.get() .get()
bet = betSnapshot.data() as Bet bet = betSnapshot.data() as Bet
answer = answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome) ? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined : undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
} }
const comments = await getValues<ContractComment>( const comments = await getValues<ContractComment>(

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) || !isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) 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() 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) return groups.docs.map((doc) => doc.data() as Group)
} }
const createContractIdForGroup = async ( // const createContractIdForGroup = async (
groupId: string, // groupId: string,
contractId: string // contractId: string
) => { // ) => {
const firestore = admin.firestore() // const firestore = admin.firestore()
const now = Date.now() // const now = Date.now()
const contractDoc = await firestore // const contractDoc = await firestore
.collection('groups') // .collection('groups')
.doc(groupId) // .doc(groupId)
.collection('groupContracts') // .collection('groupContracts')
.doc(contractId) // .doc(contractId)
.get() // .get()
if (!contractDoc.exists) // if (!contractDoc.exists)
await firestore // await firestore
.collection('groups') // .collection('groups')
.doc(groupId) // .doc(groupId)
.collection('groupContracts') // .collection('groupContracts')
.doc(contractId) // .doc(contractId)
.create({ // .create({
contractId, // contractId,
createdTime: now, // createdTime: now,
}) // })
} // }
const createMemberForGroup = async (groupId: string, userId: string) => { // const createMemberForGroup = async (groupId: string, userId: string) => {
const firestore = admin.firestore() // const firestore = admin.firestore()
const now = Date.now() // const now = Date.now()
const memberDoc = await firestore // const memberDoc = await firestore
.collection('groups') // .collection('groups')
.doc(groupId) // .doc(groupId)
.collection('groupMembers') // .collection('groupMembers')
.doc(userId) // .doc(userId)
.get() // .get()
if (!memberDoc.exists) // if (!memberDoc.exists)
await firestore // await firestore
.collection('groups') // .collection('groups')
.doc(groupId) // .doc(groupId)
.collection('groupMembers') // .collection('groupMembers')
.doc(userId) // .doc(userId)
.create({ // .create({
userId, // userId,
createdTime: now, // 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 // 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() { async function updateTotalContractsAndMembers() {
const groups = await getGroups() const groups = await getGroups()
for (const group of groups) { 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) { if (require.main === module) {
initAdmin() initAdmin()
// convertGroupFieldsToGroupDocuments() // convertGroupFieldsToGroupDocuments()
updateTotalContractsAndMembers() // updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
} }

View File

@ -1,9 +1,9 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
@ -11,8 +11,10 @@ import {
calculateCreatorVolume, calculateCreatorVolume,
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -43,11 +45,29 @@ export async function updateMetricsCore() {
.filter((contract) => contract.id) .filter((contract) => contract.id)
.map((contract) => { .map((contract) => {
const contractBets = betsByContract[contract.id] ?? [] 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 { return {
doc: firestore.collection('contracts').doc(contract.id), doc: firestore.collection('contracts').doc(contract.id),
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
}, },
} }
}) })

View File

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

View File

@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void setError: (error: string | undefined) => void
minimumAmount?: number minimumAmount?: number
disabled?: boolean disabled?: boolean
showSliderOnMobile?: boolean
className?: string className?: string
inputClassName?: string inputClassName?: string
// Needed to focus the amount input // Needed to focus the amount input
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
onChange, onChange,
error, error,
setError, setError,
showSliderOnMobile: showSlider,
disabled, disabled,
className, className,
inputClassName, inputClassName,
@ -121,15 +123,28 @@ export function BuyAmountInput(props: {
} }
return ( return (
<AmountInput <>
amount={amount} <AmountInput
onChange={onAmountChange} amount={amount}
label={ENV_CONFIG.moneyMoniker} onChange={onAmountChange}
error={error} label={ENV_CONFIG.moneyMoniker}
disabled={disabled} error={error}
className={className} disabled={disabled}
inputClassName={inputClassName} className={className}
inputRef={inputRef} 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)}> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch"> <Row className="items-center justify-between self-stretch">
<div className="text-xl"> <div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'} Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div> </div>
{!isModal && ( {!isModal && (
@ -134,8 +134,9 @@ export function AnswerBetPanel(props: {
</Row> </Row>
<Row className="my-3 justify-between text-left text-sm text-gray-500"> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount Amount
<span>(balance: {formatMoney(user?.balance ?? 0)})</span> <span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row> </Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -144,6 +145,7 @@ export function AnswerBetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} inputRef={inputRef}
showSliderOnMobile
/> />
{(betAmount ?? 0) > 10 && {(betAmount ?? 0) > 10 &&
@ -204,7 +206,7 @@ export function AnswerBetPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit trade'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

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

View File

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

View File

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

View File

@ -35,10 +35,13 @@ export default function BetButton(props: {
{user ? ( {user ? (
<Button <Button
size="lg" 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)} onClick={() => setOpen(true)}
> >
Bet Predict
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

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

View File

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

View File

@ -5,19 +5,19 @@ export function PillButton(props: {
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
color?: string color?: string
big?: boolean xs?: boolean
children: ReactNode children: ReactNode
}) { }) {
const { children, selected, onSelect, color, big } = props const { children, selected, onSelect, color, xs } = props
return ( return (
<button <button
className={clsx( 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 selected
? ['text-white', color ?? 'bg-greyscale-6'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3', : 'bg-greyscale-2 hover:bg-greyscale-3'
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)} )}
onClick={onSelect} onClick={onSelect}
> >

View File

@ -33,12 +33,12 @@ export function Carousel(props: {
}, 500) }, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, []) useEffect(onScroll, [children])
return ( return (
<div className={clsx('relative', className)}> <div className={clsx('relative', className)}>
<Row <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} ref={ref}
onScroll={onScroll} onScroll={onScroll}
> >

View File

@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
setErrorText('') setErrorText('')
}, [open]) }, [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 = () => { const iAcceptChallenge = () => {
setLoading(true) setLoading(true)

View File

@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input' import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy' import { FIXED_ANTE } from 'common/economy'
@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor' import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useWindowSize } from 'web/hooks/use-window-size'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -110,9 +110,8 @@ function CreateChallengeForm(props: {
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week' const defaultExpire = 'week'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(), expiresTime: dayjs().add(2, defaultExpire).valueOf(),
@ -158,7 +157,7 @@ function CreateChallengeForm(props: {
<Textarea <Textarea
placeholder="e.g. Will a Democrat be the next president?" placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none" className="input input-bordered mt-1 w-full resize-none"
autoFocus={!isMobile} autoFocus={true}
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question} value={challengeInfo.question}
onChange={(e) => onChange={(e) =>
@ -171,7 +170,7 @@ function CreateChallengeForm(props: {
)} )}
</div> </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> <Col>
<div>You'll bet:</div> <div>You'll bet:</div>
<Row <Row
@ -186,7 +185,9 @@ function CreateChallengeForm(props: {
return { return {
...m, ...m,
amount: newAmount ?? 0, amount: newAmount ?? 0,
acceptorAmount: newAmount ?? 0, acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
} }
}) })
} }
@ -197,7 +198,7 @@ function CreateChallengeForm(props: {
<span className={''}>on</span> <span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row> </Row>
<Row className={'max-w-xs justify-end'}> <Row className={'mt-3 max-w-xs justify-end'}>
<Button <Button
color={'gray-white'} color={'gray-white'}
onClick={() => onClick={() =>
@ -212,18 +213,50 @@ function CreateChallengeForm(props: {
<SwitchVerticalIcon className={'h-6 w-6'} /> <SwitchVerticalIcon className={'h-6 w-6'} />
</Button> </Button>
</Row> </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> </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> </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"> <div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '} If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold"> <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: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' }, { label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' }, { label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' }, { label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const ] as const
export type Sort = typeof SORTS[number]['value'] export type Sort = typeof SORTS[number]['value']
@ -66,6 +69,7 @@ type AdditionalFilter = {
excludeContractIds?: string[] excludeContractIds?: string[]
groupSlug?: string groupSlug?: string
yourBets?: boolean yourBets?: boolean
followed?: boolean
} }
export function ContractSearch(props: { export function ContractSearch(props: {
@ -85,6 +89,7 @@ export function ContractSearch(props: {
useQueryUrlParam?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
noControls?: boolean noControls?: boolean
maxResults?: number
renderContracts?: ( renderContracts?: (
contracts: Contract[] | undefined, contracts: Contract[] | undefined,
loadMore: () => void loadMore: () => void
@ -104,6 +109,7 @@ export function ContractSearch(props: {
useQueryUrlParam, useQueryUrlParam,
isWholePage, isWholePage,
noControls, noControls,
maxResults,
renderContracts, renderContracts,
} = props } = props
@ -186,7 +192,8 @@ export function ContractSearch(props: {
const contracts = state.pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .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) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -289,6 +296,19 @@ function ContractSearchControls(props: {
const pillGroups: { name: string; slug: string }[] = const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS 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 = [ const additionalFilters = [
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
@ -301,6 +321,7 @@ function ContractSearchControls(props: {
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
...(additionalFilter?.followed ? personalFilters : []),
] ]
const facetFilters = query const facetFilters = query
? additionalFilters ? additionalFilters
@ -317,17 +338,7 @@ function ContractSearchControls(props: {
state.pillFilter !== 'your-bets' state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}` ? `groupLinks.slug:${state.pillFilter}`
: '', : '',
state.pillFilter === 'personal' ...(state.pillFilter === 'personal' ? personalFilters : []),
? // 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 === 'your-bets' && user state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
@ -438,7 +449,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your trades
</PillButton> </PillButton>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{ additionalFilter={{
creatorId: creator.id, 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 { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) { export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props const { contract } = props
@ -42,7 +43,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/> />
<span>Share</span> <span>Share</span>
</Col> </Col>
<ShareModal <ShareModal
isOpen={isShareOpen} isOpen={isShareOpen}
setOpen={setShareOpen} setOpen={setShareOpen}
@ -50,17 +50,21 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user} user={user}
/> />
</Button> </Button>
{showChallenge && ( {showChallenge && (
<Button <Button
size="lg" size="lg"
color="gray-white" color="gray-white"
className={'flex hidden max-w-xs self-center sm:inline-block'} className="max-w-xs self-center"
onClick={withTracking( onClick={withTracking(
() => setOpenCreateChallengeModal(true), () => setOpenCreateChallengeModal(true),
'click challenge button' '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 <CreateChallengeModal
isOpen={openCreateChallengeModal} isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal} setOpen={setOpenCreateChallengeModal}

View File

@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
return ( return (
<Button <Button
size={'lg'} size={'lg'}
className={'mb-1'} className={'max-w-xs self-center'}
color={'gray-white'} color={'gray-white'}
onClick={onLike} onClick={onLike}
> >
<Col className={'items-center sm:flex-row sm:gap-x-2'}> <Col className={'items-center sm:flex-row'}>
<HeartIcon <HeartIcon
className={clsx( className={clsx(
'h-6 w-6', 'h-[24px] w-5 sm:mr-2',
user && user &&
(userLikedContractIds?.includes(contract.id) || (userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.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 React from 'react'
import Link from 'next/link'
import { Button } from './button' import { Button } from './button'
import { SiteLink } from 'web/components/site-link'
export const CreateQuestionButton = () => { export const CreateQuestionButton = () => {
return ( return (
<Link href="/create" passHref> <SiteLink href="/create">
<Button color="gradient" size="xl" className="mt-4"> <Button color="gradient" size="xl" className="mt-4 w-full">
Create a market Create a market
</Button> </Button>
</Link> </SiteLink>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { import { createCommentOnContract } from 'web/lib/firebase/comments'
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets' import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate' 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 { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper' import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' 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 { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
user: User | null | undefined 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" className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome} parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => {
setShowReply(false)
}}
/> />
</Col> </Col>
)} )}
@ -125,15 +123,12 @@ export function FeedComment(props: {
} = props } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment comment
let betOutcome: string | undefined, const betOutcome = comment.betOutcome
bought: string | undefined, let bought: string | undefined
money: string | undefined let money: string | undefined
if (comment.betAmount != null) {
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) bought = comment.betAmount >= 0 ? 'bought' : 'sold'
if (matchedBet) { money = formatMoney(Math.abs(comment.betAmount))
betOutcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
} }
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
@ -148,7 +143,7 @@ export function FeedComment(props: {
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
comment.createdTime, comment.createdTime,
matchedBet ? [] : betsBySameUser comment.betId ? [] : betsBySameUser
) )
return ( return (
@ -175,7 +170,7 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{!matchedBet && {!comment.betId != null &&
userPosition > 0 && userPosition > 0 &&
contract.outcomeType !== 'NUMERIC' && ( contract.outcomeType !== 'NUMERIC' && (
<> <>
@ -194,7 +189,6 @@ export function FeedComment(props: {
of{' '} of{' '}
<OutcomeLabel <OutcomeLabel
outcome={betOutcome ? betOutcome : ''} outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />
@ -271,67 +265,76 @@ function CommentStatus(props: {
) )
} }
//TODO: move commentinput and comment input text area into their own files export function ContractCommentInput(props: {
export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[] commentsByCurrentUser: ContractComment[]
className?: string className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string parentCommentId?: string
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser() const user = useUser()
const { editor, upload } = useTextEditor({ async function onSubmitComment(editor: Editor, betId: string | undefined) {
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) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract( await createCommentOnContract(
contract.id, props.contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
betId, betId,
parentAnswerOutcome, props.parentAnswerOutcome,
parentCommentId props.parentCommentId
) )
onSubmitComment?.() props.onSubmitComment?.()
setIsSubmitting(false)
} }
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( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
Date.now(), Date.now(),
@ -340,158 +343,36 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC' const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar <div className="mb-1 text-gray-500">
avatarUrl={user?.avatarUrl} {mostRecentCommentableBet && (
username={user?.username} <BetStatusText
size="sm" contract={contract}
className="mt-2" bet={mostRecentCommentableBet}
/> isSelf={true}
<div className="min-w-0 flex-1 pl-0.5 text-sm"> hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
<div className="mb-1 text-gray-500"> />
{mostRecentCommentableBet && ( )}
<BetStatusText {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract} contract={contract}
bet={mostRecentCommentableBet} prob={
isSelf={true} contract.outcomeType === 'BINARY'
hideOutcome={ ? getProbability(contract)
isNumeric || contract.outcomeType === 'FREE_RESPONSE' : 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> </div>
</Row> </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( function getBettorsLargestPositionBeforeTime(
contract: Contract, contract: Contract,
createdTime: number, createdTime: number,

View File

@ -5,6 +5,7 @@ import {
CheckIcon, CheckIcon,
PlusCircleIcon, PlusCircleIcon,
SelectorIcon, SelectorIcon,
UserIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button' 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 { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
import { User } from 'common/user' import { User } from 'common/user'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { Row } from 'web/components/layout/row'
export function GroupSelector(props: { export function GroupSelector(props: {
selectedGroup: Group | undefined selectedGroup: Group | undefined
@ -28,13 +30,27 @@ export function GroupSelector(props: {
const { showSelector, showLabel, ignoreGroupIds } = options const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const openGroups = useOpenGroups() const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups const availableGroups = openGroups
.concat( .concat(
(useMemberGroups(creator?.id) ?? []).filter( (memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id) (g) => !openGroups.map((og) => og.id).includes(g.id)
) )
) )
.filter((group) => !ignoreGroupIds?.includes(group.id)) .filter((group) => !ignoreGroupIds?.includes(group.id))
.sort((a, b) => b.totalContracts - a.totalContracts)
// put the groups the user is a member of first
.sort((a, b) => {
if (memberGroupIds.includes(a.id)) {
return -1
}
if (memberGroupIds.includes(b.id)) {
return 1
}
return 0
})
const filteredGroups = availableGroups.filter((group) => const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name) searchInAny(query, group.name)
) )
@ -96,7 +112,7 @@ export function GroupSelector(props: {
value={group} value={group}
className={({ active }) => className={({ active }) =>
clsx( 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' active ? 'bg-indigo-500 text-white' : 'text-gray-900'
) )
} }
@ -115,11 +131,28 @@ export function GroupSelector(props: {
)} )}
<span <span
className={clsx( className={clsx(
'ml-5 mt-1 block truncate', 'ml-3 mt-1 block flex flex-row justify-between',
selected && 'font-semibold' 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> </span>
</> </>
)} )}

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=bets`}> <Link href={`/${user.username}?tab=trades`}>
<a <a
onClick={trackCallback('sidebar: profile')} 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" 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} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit bet'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
)} )}

View File

@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money betting" title="Play-money trading"
className="mt-4 max-w-md" 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: { export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[] portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit'
height?: number height?: number
includeTime?: boolean includeTime?: boolean
}) { }) {
const { portfolioHistory, height, includeTime } = props const { portfolioHistory, height, includeTime, mode } = props
const { width } = useWindowSize() const { width } = useWindowSize()
const points = portfolioHistory.map((p) => { const points = portfolioHistory.map((p) => {
const { timestamp, balance, investmentValue, totalDeposits } = p
const value = balance + investmentValue
const profit = value - totalDeposits
return { return {
x: new Date(p.timestamp), x: new Date(timestamp),
y: p.balance + p.investmentValue, y: mode === 'value' ? value : profit,
} }
}) })
const data = [{ id: 'Value', data: points, color: '#11b981' }] 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 { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import { PortfolioValueGraph } from './portfolio-value-graph' import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo( export const PortfolioValueSection = memo(
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
return <></> return <></>
} }
const { balance, investmentValue } = lastPortfolioMetrics const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
const totalValue = balance + investmentValue const totalValue = balance + investmentValue
const totalProfit = totalValue - totalDeposits
return ( return (
<> <>
<Row className="gap-8"> <Row className="gap-8">
<Col className="flex-1 justify-center"> <Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div> <div className="text-sm text-gray-500">Profit</div>
<div className="text-lg">{formatMoney(totalValue)}</div> <div className="text-lg">{formatMoney(totalProfit)}</div>
</Col> </Col>
<select <select
className="select select-bordered self-start" className="select select-bordered self-start"
@ -42,6 +44,7 @@ export const PortfolioValueSection = memo(
}} }}
> >
<option value="allTime">All time</option> <option value="allTime">All time</option>
<option value="monthly">Last Month</option>
<option value="weekly">Last 7d</option> <option value="weekly">Last 7d</option>
<option value="daily">Last 24h</option> <option value="daily">Last 24h</option>
</select> </select>
@ -49,6 +52,17 @@ export const PortfolioValueSection = memo(
<PortfolioValueGraph <PortfolioValueGraph
portfolioHistory={currPortfolioHistory} portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'} 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}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span> <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'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span> <span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

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

View File

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

View File

@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
comment.commentType === 'contract' ? comment.contractId : undefined comment.commentType === 'contract' ? comment.contractId : undefined
const groupId = const groupId =
comment.commentType === 'group' ? comment.groupId : undefined comment.commentType === 'group' ? comment.groupId : undefined
const postId = comment.commentType === 'post' ? comment.postId : undefined
await transact({ await transact({
amount: change, amount: change,
fromId: user.id, fromId: user.id,
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER', toType: 'USER',
token: 'M$', token: 'M$',
category: 'TIP', 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`, 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, commentId: comment.id,
contractId, contractId,
groupId, groupId,
postId,
amount: change, amount: change,
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,

View File

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

View File

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

View File

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

View File

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

View File

@ -103,6 +103,7 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => {
isEnd: state.isComplete && state.pageEnd >= state.docs.length, isEnd: state.isComplete && state.pageEnd >= state.docs.length,
getPrev: () => dispatch({ type: 'PREV' }), getPrev: () => dispatch({ type: 'PREV' }),
getNext: () => dispatch({ type: 'NEXT' }), getNext: () => dispatch({ type: 'NEXT' }),
allItems: () => state.docs.map((d) => d.data()),
getItems: () => getItems: () =>
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()), 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 { import {
listenForTipTxns, listenForTipTxns,
listenForTipTxnsOnGroup, listenForTipTxnsOnGroup,
listenForTipTxnsOnPost,
} from 'web/lib/firebase/txns' } from 'web/lib/firebase/txns'
export type CommentTips = { [userId: string]: number } export type CommentTips = { [userId: string]: number }
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
export function useTipTxns(on: { export function useTipTxns(on: {
contractId?: string contractId?: string
groupId?: string groupId?: string
postId?: string
}): CommentTipMap { }): CommentTipMap {
const [txns, setTxns] = useState<TipTxn[]>([]) const [txns, setTxns] = useState<TipTxn[]>([])
const { contractId, groupId } = on const { contractId, groupId, postId } = on
useEffect(() => { useEffect(() => {
if (contractId) return listenForTipTxns(contractId, setTxns) if (contractId) return listenForTipTxns(contractId, setTxns)
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
}, [contractId, groupId, setTxns]) if (postId) return listenForTipTxnsOnPost(postId, setTxns)
}, [contractId, groupId, postId, setTxns])
return useMemo(() => { return useMemo(() => {
const byComment = groupBy(txns, 'data.commentId') const byComment = groupBy(txns, 'data.commentId')

View File

@ -1,8 +1,14 @@
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react' 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(() => { useEffect(() => {
if (excludeIframe && inIframe()) return
track(eventName, eventProperties) track(eventName, eventProperties)
}, []) }, [])
} }

View File

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

View File

@ -16,7 +16,7 @@ import {
import { partition, sortBy, sum, uniqBy } from 'lodash' import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils' 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 { createRNG, shuffle } from 'common/util/random'
import { formatMoney, formatPercent } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time' 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()) 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( export async function listContractsByGroupSlug(
slug: string slug: string
): Promise<Contract[]> { ): Promise<Contract[]> {
@ -395,3 +403,21 @@ export async function getRecentBetsAndComments(contract: Contract) {
recentComments, 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, updateDoc,
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { uniq } from 'lodash' import { uniq, uniqBy } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import { import {
coll, coll,
@ -21,7 +21,7 @@ import {
listenForValues, listenForValues,
} from './utils' } from './utils'
import { Contract } from 'common/contract' 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 { db } from 'web/lib/firebase/init'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { getUser } from 'web/lib/firebase/users' import { getUser } from 'web/lib/firebase/users'
@ -31,6 +31,9 @@ export const groupMembers = (groupId: string) =>
collection(groups, groupId, 'groupMembers') collection(groups, groupId, 'groupMembers')
export const groupContracts = (groupId: string) => export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts') 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( export function groupPath(
groupSlug: string, groupSlug: string,
@ -78,23 +81,25 @@ export function listenForGroupContractDocs(
return listenForValues(groupContracts(groupId), setContractDocs) return listenForValues(groupContracts(groupId), setContractDocs)
} }
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { export async function listGroupContracts(groupId: string) {
return listenForValues( const contractDocs = await getValues<{
query(groups, where('anyoneCanJoin', '==', true)), contractId: string
setGroups 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) { export function getGroup(groupId: string) {
return getValue<Group>(doc(groups, groupId)) 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) { export async function getGroupBySlug(slug: string) {
const q = query(groups, where('slug', '==', slug)) const q = query(groups, where('slug', '==', slug))
const docs = (await getDocs(q)).docs const docs = (await getDocs(q)).docs
@ -108,14 +113,20 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup) 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( export function listenForMemberGroupIds(
userId: string, userId: string,
setGroupIds: (groupIds: string[]) => void setGroupIds: (groupIds: string[]) => void
) { ) {
const q = query( const q = memberGroupsQuery(userId)
collectionGroup(db, 'groupMembers'),
where('userId', '==', userId)
)
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => { return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return 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) { export async function addUserToGroupViaId(groupId: string, userId: string) {
// get group to get the member ids // get group to get the member ids
const group = await getGroup(groupId) const group = await getGroup(groupId)

View File

@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
where('data.groupId', '==', groupId) where('data.groupId', '==', groupId)
) )
const getTipsOnPostQuery = (postId: string) =>
query(
txns,
where('category', '==', 'TIP'),
where('data.postId', '==', postId)
)
export function listenForTipTxns( export function listenForTipTxns(
contractId: string, contractId: string,
setTxns: (txns: TipTxn[]) => void setTxns: (txns: TipTxn[]) => void
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns) 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 // Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) { export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([]) 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, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,
experimental: { experimental: {
images: {
allowFutureImage: true,
},
scrollRestoration: true, scrollRestoration: true,
externalDir: true, externalDir: true,
modularizeImports: { modularizeImports: {
@ -25,7 +28,12 @@ module.exports = {
}, },
}, },
images: { images: {
domains: ['lh3.googleusercontent.com', 'i.imgur.com'], domains: [
'manifold.markets',
'lh3.googleusercontent.com',
'i.imgur.com',
'firebasestorage.googleapis.com',
],
}, },
async redirects() { async redirects() {
return [ return [

View File

@ -69,7 +69,7 @@ export async function getStaticPropz(props: {
comments: comments.slice(0, 1000), 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 const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id) usePrefetch(user?.id)
useTracking('view market', { useTracking(
slug: contract.slug, 'view market',
contractId: contract.id, {
creatorId: contract.creatorId, slug: contract.slug,
}) contractId: contract.id,
creatorId: contract.creatorId,
},
true
)
const bets = useBets(contract.id) ?? props.bets const bets = useBets(contract.id) ?? props.bets
const nonChallengeBets = useMemo( 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 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 { 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( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<Data> res: NextApiResponse
) { ) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED) 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() const groups = await listAllGroups()
res.setHeader('Cache-Control', 'max-age=0') res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups) res.status(200).json(groups)

View File

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

View File

@ -21,6 +21,7 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
import { listAllBets } from 'web/lib/firebase/bets' import { listAllBets } from 'web/lib/firebase/bets'
import { import {
contractPath, contractPath,
@ -82,6 +83,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props const { contract, bets } = props
const { question, outcomeType } = contract const { question, outcomeType } = contract
useTracking('view market embed', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' 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 React, { useState } from 'react'
import Router from 'next/router' 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 { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search' import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user' import { User } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/components/contract-search' import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group' import { Group } from 'common/group'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { GroupLinkItem } from '../../groups'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { DoubleCarousel } from '../../../components/double-carousel'
import clsx from 'clsx'
import { Button } from 'web/components/button' 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 { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row' 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 Home = () => {
const creds = await authenticateOnServer(ctx) const user = useUser()
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
useTracking('view home') useTracking('view home')
@ -41,71 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => {
const groups = useMemberGroups(user?.id) ?? [] const groups = useMemberGroups(user?.id) ?? []
const [homeSections, setHomeSections] = useState( const [homeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] } user?.homeSections ?? { visible: [], hidden: [] }
) )
const { visibleItems } = getHomeItems(groups, homeSections) 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 ( return (
<Page> <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'}> <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> </Row>
{isEditing ? ( <DailyProfitAndBalance userId={user?.id} />
<>
<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}
/>
)
const group = groups.find((g) => g.id === id) <div className="text-xl text-gray-800">Daily movers</div>
if (group) <ProbChangeTable userId={user?.id} />
return <GroupSection key={id} group={group} user={user} />
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> </Col>
<button <button
type="button" type="button"
@ -123,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => {
function SearchSection(props: { function SearchSection(props: {
label: string label: string
user: User | null user: User | null | undefined
sort: Sort sort: Sort
yourBets?: boolean yourBets?: boolean
}) { }) {
@ -133,88 +113,91 @@ function SearchSection(props: {
return ( return (
<Col> <Col>
<SiteLink className="mb-2 text-xl" href={href}> <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> </SiteLink>
<ContractSearch <ContractSearch
user={user} user={user}
defaultSort={sort} defaultSort={sort}
additionalFilter={yourBets ? { yourBets: true } : undefined} additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
noControls noControls
// persistPrefix={`experimental-home-${sort}`} maxResults={6}
renderContracts={(contracts, loadMore) => persistPrefix={`experimental-home-${sort}`}
contracts ? (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={href}
showTime={
sort === 'close-date' || sort === 'resolve-date'
? sort
: undefined
}
loadMore={loadMore}
/>
) : (
<LoadingIndicator />
)
}
/> />
</Col> </Col>
) )
} }
function GroupSection(props: { group: Group; user: User | null }) { function GroupSection(props: { group: Group; user: User | null | undefined }) {
const { group, user } = props const { group, user } = props
return ( return (
<Col> <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 <ContractSearch
user={user} user={user}
defaultSort={'score'} defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
noControls noControls
// persistPrefix={`experimental-home-${group.slug}`} maxResults={6}
renderContracts={(contracts, loadMore) => persistPrefix={`experimental-home-${group.slug}`}
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 />
)
}
/> />
</Col> </Col>
) )
} }
function EditDoneButton(props: { function EditButton(props: { className?: string }) {
isEditing: boolean const { className } = props
setIsEditing: (isEditing: boolean) => void
className?: string
}) {
const { isEditing, setIsEditing, className } = props
return ( return (
<Button <SiteLink href="/experimental/home/edit">
size="lg" <Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
color={isEditing ? 'blue' : 'gray-white'} <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
className={clsx(className, 'flex')} Edit
onClick={() => { </Button>
setIsEditing(!isEditing) </SiteLink>
}} )
> }
{!isEditing && (
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> function DailyProfitAndBalance(props: {
)} userId: string | null | undefined
{isEditing ? 'Done' : 'Edit'} className?: string
</Button> }) {
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 { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -226,6 +227,7 @@ export default function GroupPage(props: {
defaultSort={'newest'} defaultSort={'newest'}
defaultFilter={suggestedFilter} defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/> />
) )
@ -659,22 +661,25 @@ function JoinGroupButton(props: {
user: User | null | undefined user: User | null | undefined
}) { }) {
const { group, user } = props const { group, user } = props
function addUserToGroup() {
if (user) { const follow = async () => {
toast.promise(joinGroup(group, user.id), { track('join group')
loading: 'Joining group...', const userId = user ? user.id : (await firebaseLogin()).user.uid
success: 'Joined group!',
error: "Couldn't join group, try again?", toast.promise(joinGroup(group, userId), {
}) loading: 'Following group...',
} success: 'Followed',
error: "Couldn't follow group, try again?",
})
} }
return ( return (
<div> <div>
<button <button
onClick={user ? addUserToGroup : firebaseLogin} onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'} className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
> >
{user ? 'Join group' : 'Login to join group'} Follow
</button> </button>
</div> </div>
) )

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { Page } from 'web/components/page' 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 { Post } from 'common/post'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer' 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 { 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 clsx from 'clsx'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { useState } from 'react' import { useState } from 'react'
@ -16,17 +16,27 @@ import { Col } from 'web/components/layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import Custom404 from 'web/pages/404' import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-link' 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[] } }) { export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params const { slugs } = props.params
const post = await getPostBySlug(slugs[0]) const post = await getPostBySlug(slugs[0])
const creator = post ? await getUser(post.creatorId) : null const creator = post ? await getUser(post.creatorId) : null
const comments = post && (await listAllCommentsOnPost(post.id))
return { return {
props: { props: {
post: post, post: post,
creator: creator, creator: creator,
comments: comments,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -37,28 +47,38 @@ export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' } 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 [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 /> return <Custom404 />
} }
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
return ( return (
<Page> <Page>
<div className="mx-auto w-full max-w-3xl "> <div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} /> <Spacer h={1} />
<Title className="!mt-0" text={props.post.title} /> <Title className="!mt-0" text={post.title} />
<Row> <Row>
<Col className="flex-1"> <Col className="flex-1">
<div className={'inline-flex'}> <div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div> <div className="mr-1 text-gray-500">Created by</div>
<UserLink <UserLink
className="text-neutral" className="text-neutral"
name={props.creator.name} name={creator.name}
username={props.creator.username} username={creator.username}
/> />
</div> </div>
</Col> </Col>
@ -88,10 +108,121 @@ export default function PostPage(props: { post: Post; creator: User }) {
<Spacer h={2} /> <Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<div className="form-control w-full py-2"> <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>
</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> </div>
</Page> </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 { ClockIcon } from '@heroicons/react/outline'
import { UsersIcon } from '@heroicons/react/solid' import { UsersIcon } from '@heroicons/react/solid'
import { import dayjs from 'dayjs'
BinaryContract,
Contract,
PseudoNumericContract,
} from 'common/contract'
import { Group } from 'common/group'
import dayjs, { Dayjs } from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat' import customParseFormat from 'dayjs/plugin/customParseFormat'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import { keyBy, mapValues, sortBy } from 'lodash' import { zip } from 'lodash'
import Image, { ImageProps, StaticImageData } from 'next/image' import Image, { ImageProps, StaticImageData } from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO' 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 { getGroup, groupPath } from 'web/lib/firebase/groups'
import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png' import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png'
import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png' import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png'
import mpox_pic from './_cspi/Monkeypox_Cases.png' import mpox_pic from './_cspi/Monkeypox_Cases.png'
import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png' import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { getProbability } from 'common/calculate'
import { Carousel } from 'web/components/carousel' 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(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
dayjs.extend(customParseFormat) 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 = { type Tourney = {
title: string title: string
url?: string
blurb: string // actual description in the click-through blurb: string // actual description in the click-through
award?: string award?: string
endTime?: Dayjs endTime?: number
groupId: string groupId: string
} }
@ -50,7 +50,7 @@ const Salem = {
url: 'https://salemcenter.manifold.markets/', url: 'https://salemcenter.manifold.markets/',
award: '$25,000', award: '$25,000',
endTime: toDate('Jul 31, 2023'), endTime: toDate('Jul 31, 2023'),
markets: [], contractIds: [],
images: [ images: [
{ {
marketUrl: marketUrl:
@ -107,33 +107,27 @@ const tourneys: Tourney[] = [
// }, // },
] ]
export async function getStaticProps() { type SectionInfo = {
const groupIds = tourneys tourney: Tourney
.map((data) => data.groupId) slug: string
.filter((id) => id != undefined) as string[] numPeople: number
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 }
} }
export default function TournamentPage(props: { export async function getStaticProps() {
markets: { [groupId: string]: Contract[] } const groupIds = tourneys.map((data) => data.groupId)
numPeople: { [groupId: string]: number } const groups = await Promise.all(groupIds.map(getGroup))
slugs: { [groupId: string]: string } const sections = zip(tourneys, groups)
}) { .filter(([_tourney, group]) => group != null)
const { markets = {}, numPeople = {}, slugs = {} } = props .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 ( return (
<Page> <Page>
@ -141,96 +135,114 @@ export default function TournamentPage(props: {
title="Tournaments" title="Tournaments"
description="Win money by betting in forecasting touraments on current events, sports, science, and more" 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%]"> <Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
{tourneys.map(({ groupId, ...data }) => ( {sections.map(({ tourney, slug, numPeople }) => (
<Section <div key={slug}>
key={groupId} <SectionHeader
{...data} url={groupPath(slug)}
url={groupPath(slugs[groupId])} title={tourney.title}
ppl={numPeople[groupId] ?? 0} ppl={numPeople}
markets={markets[groupId] ?? []} 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> </Col>
</Page> </Page>
) )
} }
function Section(props: { const SectionHeader = (props: {
title: string
url: string url: string
blurb: string title: string
award?: string
ppl?: number ppl?: number
endTime?: Dayjs award?: string
markets: Contract[] endTime?: number
images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi }) => {
}) { const { url, title, ppl, award, endTime } = props
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)
return ( return (
<div> <Link href={url}>
<Link href={url}> <a className="group mb-3 flex flex-wrap justify-between">
<a className="group mb-3 flex flex-wrap justify-between"> <h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> {title}
{title} </h2>
</h2> <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
<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>}
{!!award && <span className="flex items-center">🏆 {award}</span>} {!!ppl && (
{!!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"> <span className="flex items-center gap-1">
<UsersIcon className="h-4" /> <ClockIcon className="h-4" />
{ppl} {dayjs(endTime).format('MMM D')}
</span> </span>
)} </DateTimeTooltip>
{endTime && ( )}
<DateTimeTooltip time={endTime.valueOf()} text="Ends"> </Row>
<span className="flex items-center gap-1"> </a>
<ClockIcon className="h-4" /> </Link>
{endTime.format('MMM D')} )
</span> }
</DateTimeTooltip>
)} const ImageCarousel = (props: { images: MarketImage[]; url: string }) => {
</Row> 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> </a>
</Link> ))}
<span>{blurb}</span> <SiteLink
<Carousel className="-mx-4 mt-2 sm:-mx-10"> className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
<div className="shrink-0 sm:w-6" /> href={url}
{markets.length ? ( >
markets.map((m) => ( See more
<ContractCard </SiteLink>
contract={m} </Carousel>
hideGroupLink )
className="mb-2 max-h-[200px] w-96 shrink-0" }
questionClass="line-clamp-3"
trackingPostfix=" tournament" const MarketCarousel = (props: { slug: string }) => {
/> const { slug } = props
)) const q = tournamentContractsByGroupSlugQuery(slug)
) : ( const { allItems, getNext } = usePagination({ q, pageSize: 6 })
<> const items = allItems()
{images?.map(({ marketUrl, image }) => (
<a href={marketUrl} className="hover:brightness-95"> // todo: would be nice to have indicator somewhere when it loads next page
<NaturalImage src={image} /> return items.length === 0 ? (
</a> <LoadingIndicator className="mt-10" />
))} ) : (
<SiteLink <Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}>
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" <div className="shrink-0 sm:w-6" />
href={url} {items.map((m) => (
> <ContractCard
See more key={m.id}
</SiteLink> contract={m}
</> hideGroupLink
)} className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
</Carousel> questionClass="line-clamp-3"
</div> 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>
)
}