Merge branch 'main' into profit-loss

This commit is contained in:
Ian Philips 2022-09-23 16:07:00 -04:00 committed by GitHub
commit c9dacd4739
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 1750 additions and 1829 deletions

View File

@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
dailyScore?: number
followerCount?: number
featuredOnHomeRank?: number
likedByUserIds?: string[]

View File

@ -10,6 +10,7 @@ export type Group = {
totalContracts: number
totalMembers: number
aboutPostId?: string
postIds: string[]
chatDisabled?: boolean
mostRecentContractAddedTime?: number
cachedLeaderboard?: {

View File

@ -582,6 +582,13 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}'
```
### `POST /v0/market/[marketId]/close`
Closes a market on behalf of the authorized user.
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
### `POST /v0/market/[marketId]/resolve`
Resolves a market on behalf of the authorized user.

View File

@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets.
## Sites using Manifold
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
## API / Dev
@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
## Writeups
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets.
## Art
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)
## Alumni
_These projects are no longer active, but were really really cool!_
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government

View File

@ -4,11 +4,7 @@
### Do I have to pay real money in order to participate?
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
### What is the name for the currency Manifold uses, represented by M$?
Manifold Dollars, or mana for short.
Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
### Can M$ be sold for real money?

View File

@ -100,6 +100,20 @@
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",

View File

@ -0,0 +1,58 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { getUser } from './utils'
import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { APIError, newEndpoint, validate } from './api'
const bodySchema = z.object({
contractId: z.string(),
closeTime: z.number().int().nonnegative().optional(),
})
export const closemarket = newEndpoint({}, async (req, auth) => {
const { contractId, closeTime } = validate(bodySchema, req.body)
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId } = contract
const firebaseUser = await admin.auth().getUser(auth.uid)
if (
creatorId !== auth.uid &&
!isManifoldId(auth.uid) &&
!isAdmin(firebaseUser.email)
)
throw new APIError(403, 'User is not creator of contract')
const now = Date.now()
if (!closeTime && contract.closeTime && contract.closeTime < now)
throw new APIError(400, 'Contract already closed')
if (closeTime && closeTime < now)
throw new APIError(
400,
'Close time must be in the future. ' +
'Alternatively, do not provide a close time to close immediately.'
)
const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found')
const updatedContract = {
...contract,
closeTime: closeTime ? closeTime : now,
}
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'closed')
return updatedContract
})
const firestore = admin.firestore()

View File

@ -61,6 +61,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
anyoneCanJoin,
totalContracts: 0,
totalMembers: memberIds.length,
postIds: [],
}
await groupRef.create(group)

View File

@ -34,11 +34,12 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema,
groupId: z.string().optional(),
})
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body)
const { title, content, groupId } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
@ -60,6 +61,18 @@ export const createpost = newEndpoint({}, async (req, auth) => {
}
await postRef.create(post)
if (groupId) {
const groupRef = firestore.collection('groups').doc(groupId)
const group = await groupRef.get()
if (group.exists) {
const groupData = group.data()
if (groupData) {
const postIds = groupData.postIds ?? []
postIds.push(postRef.id)
await groupRef.update({ postIds })
}
}
}
return { status: 'success', post }
})

View File

@ -51,7 +51,7 @@ export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
export * from './test-scheduled-function'
export * from './close-market'
import { health } from './health'
import { transact } from './transact'
@ -68,13 +68,13 @@ import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -94,6 +94,7 @@ const addLiquidityFunction = toCloudFunction(addliquidity)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
@ -101,7 +102,6 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const testScheduledFunction = toCloudFunction(testscheduledfunction)
export {
healthFunction as health,
@ -119,6 +119,7 @@ export {
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket,
unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
@ -126,5 +127,4 @@ export {
acceptChallenge as acceptchallenge,
createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials,
testScheduledFunction as testscheduledfunction,
}

View File

@ -22,6 +22,60 @@ import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
function getMostRecentCommentableBet(
before: number,
betsByCurrentUser: Bet[],
commentsByCurrentUser: ContractComment[],
answerOutcome?: string
) {
let sortedBetsByCurrentUser = betsByCurrentUser.sort(
(a, b) => b.createdTime - a.createdTime
)
if (answerOutcome) {
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
}
return sortedBetsByCurrentUser
.filter((bet) => {
const { createdTime, isRedemption } = bet
// You can comment on bets posted in the last hour
const commentable = !isRedemption && before - createdTime < 60 * 60 * 1000
const alreadyCommented = commentsByCurrentUser.some(
(comment) => comment.createdTime > bet.createdTime
)
if (commentable && !alreadyCommented) {
if (!answerOutcome) return true
return answerOutcome === bet.outcome
}
return false
})
.pop()
}
async function getPriorUserComments(
contractId: string,
userId: string,
before: number
) {
const priorCommentsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('comments')
.where('createdTime', '<', before)
.where('userId', '==', userId)
.get()
return priorCommentsQuery.docs.map((d) => d.data() as ContractComment)
}
async function getPriorContractBets(contractId: string, before: number) {
const priorBetsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.where('createdTime', '<', before)
.get()
return priorBetsQuery.docs.map((d) => d.data() as Bet)
}
export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}')
@ -55,17 +109,33 @@ export const onCreateCommentOnContract = functions
.doc(contract.id)
.update({ lastCommentTime, lastUpdatedTime: Date.now() })
const previousBetsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.where('createdTime', '<', comment.createdTime)
.get()
const previousBets = previousBetsQuery.docs.map((d) => d.data() as Bet)
const position = getLargestPosition(
contract,
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
const priorBets = await getPriorContractBets(
contractId,
comment.createdTime
)
const priorUserBets = priorBets.filter(
(b) => b.userId === comment.userId && !b.isAnte
)
const priorUserComments = await getPriorUserComments(
contractId,
comment.userId,
comment.createdTime
)
const bet = getMostRecentCommentableBet(
comment.createdTime,
priorUserBets,
priorUserComments,
comment.answerOutcome
)
if (bet) {
await change.ref.update({
betId: bet.id,
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const position = getLargestPosition(contract, priorUserBets)
if (position) {
const fields: { [k: string]: unknown } = {
commenterPositionShares: position.shares,
@ -73,7 +143,7 @@ export const onCreateCommentOnContract = functions
}
const previousProb =
contract.outcomeType === 'BINARY'
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
? maxBy(priorBets, (bet) => bet.createdTime)?.probAfter
: undefined
if (previousProb != null) {
fields.commenterPositionProb = previousProb
@ -81,7 +151,6 @@ export const onCreateCommentOnContract = functions
await change.ref.update(fields)
}
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.answerOutcome) {
answer =
@ -90,23 +159,6 @@ export const onCreateCommentOnContract = functions
(answer) => answer.id === comment.answerOutcome
)
: undefined
} else if (comment.betId) {
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const comments = await getValues<ContractComment>(

View File

@ -4,8 +4,14 @@ import * as utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
import { getPrivateUser } from './utils'
import { User } from '../../common/user'
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
import { User } from 'common/user'
import {
sendCreatorGuideEmail,
sendInterestingMarketsEmail,
sendPersonalFollowupEmail,
sendWelcomeEmail,
} from './emails'
import { getTrendingContracts } from './weekly-markets-emails'
export const onCreateUser = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -19,4 +25,21 @@ export const onCreateUser = functions
const followupSendTime = dayjs().add(48, 'hours').toString()
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
const guideSendTime = dayjs().add(96, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
// skip email if weekly email is about to go out
const day = dayjs().utc().day()
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
const contracts = await getTrendingContracts()
const marketsSendTime = dayjs().add(24, 'hours').toString()
await sendInterestingMarketsEmail(
user,
privateUser,
contracts,
marketsSendTime
)
})

View File

@ -1,12 +1,14 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { log } from './utils'
import { removeUndefinedProps } from '../../common/util/object'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
export const scoreContracts = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
@ -44,11 +46,22 @@ async function scoreContractsInternal() {
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
const popularityScore = uniq(bettors).length
let dailyScore: number | undefined
if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') {
const percentChange = Math.abs(contract.probChanges.day)
dailyScore = popularityScore * percentChange
}
if (
contract.popularityScore !== popularityScore ||
contract.dailyScore !== dailyScore
) {
await firestore
.collection('contracts')
.doc(contract.id)
.update({ popularityScore: score })
.update(removeUndefinedProps({ popularityScore, dailyScore }))
}
}
}

View File

@ -41,6 +41,7 @@ const createGroup = async (
anyoneCanJoin: true,
totalContracts: contracts.length,
totalMembers: 1,
postIds: [],
}
await groupRef.create(group)
// create a GroupMemberDoc for the creator

View File

@ -12,7 +12,7 @@ import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '8GB', timeoutSeconds: 540 })
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')

View File

@ -22,7 +22,7 @@ import { Group } from 'common/group'
const firestore = admin.firestore()
export const updateMetrics = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -343,6 +343,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
<div className="modal">
<div className="modal-box">
<div className="mb-6 text-xl">Get Manifold Dollars</div>
<div className="mb-6 text-xl">Get Mana</div>
<div className="mb-6 text-gray-500">
Use Manifold Dollars to trade in your favorite markets. <br /> (Not
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
</div>

View File

@ -1,4 +1,4 @@
import { sortBy, partition, sum, uniq } from 'lodash'
import { sortBy, partition, sum } from 'lodash'
import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
import clsx from 'clsx'
@ -39,22 +38,14 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
)
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
const [winningAnswers, losingAnswers] = partition(
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
(answer) =>
answer.id === resolution || (resolutions && resolutions[answer.id])
const [winningAnswers, notWinningAnswers] = partition(
answers,
(a) => a.id === resolution || (resolutions && resolutions[a.id])
)
const [visibleAnswers, invisibleAnswers] = partition(
sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)),
(a) => showAllAnswers || totalBets[a.id] > 0
)
const sortedAnswers = [
...sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0
),
...sortBy(
resolution ? [] : losingAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
),
]
const user = useUser()
@ -67,12 +58,6 @@ export function AnswersPanel(props: {
const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
user
)
const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob })
@ -109,13 +94,13 @@ export function AnswersPanel(props: {
return (
<Col className="gap-3">
{(resolveOption || resolution) &&
sortedAnswers.map((answer) => (
sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => (
<AnswerItem
key={answer.id}
answer={answer}
key={a.id}
answer={a}
contract={contract}
showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]}
chosenProb={chosenAnswers[a.id]}
totalChosenProb={chosenTotal}
onChoose={onChoose}
onDeselect={onDeselect}
@ -123,31 +108,29 @@ export function AnswersPanel(props: {
))}
{!resolveOption && (
<div className={clsx('flow-root pr-2 md:pr-0')}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{answerItems.map((item) => (
<div key={item.id} className={'relative pb-2'}>
<div className="relative flex items-start space-x-3">
<OpenAnswer {...item} />
</div>
</div>
))}
<Row className={'justify-end'}>
{hasZeroBetAnswers && !showAllAnswers && (
<Button
color={'gray-white'}
onClick={() => setShowAllAnswers(true)}
size={'md'}
>
Show More
</Button>
)}
</Row>
</div>
</div>
<Col
className={clsx(
'gap-2 pr-2 md:pr-0',
tradingAllowed(contract) ? '' : '-mb-6'
)}
>
{visibleAnswers.map((a) => (
<OpenAnswer key={a.id} answer={a} contract={contract} />
))}
{invisibleAnswers.length > 0 && !showAllAnswers && (
<Button
className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)}
size="md"
>
Show More
</Button>
)}
</Col>
)}
{answers.length <= 1 && (
{answers.length === 0 && (
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
@ -175,35 +158,9 @@ export function AnswersPanel(props: {
)
}
function getAnswerItems(
contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[],
user: User | undefined | null
) {
let outcomes = uniq(answers.map((answer) => answer.number.toString()))
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()
return outcomes
.map((outcome) => {
const answer = answers.find((answer) => answer.id === outcome) as Answer
//unnecessary
return {
id: outcome,
type: 'answer' as const,
contract,
answer,
user,
}
})
.filter((group) => group.answer)
}
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
type: string
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
@ -212,7 +169,7 @@ function OpenAnswer(props: {
const [open, setOpen] = useState(false)
return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
<Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel
answer={answer}
@ -229,37 +186,30 @@ function OpenAnswer(props: {
/>
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
<Linkify className="whitespace-pre-line text-lg" text={text} />
<Row className="align-items items-center justify-end gap-4">
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</Row>
</Col>
</Col>

View File

@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser
export type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'

View File

@ -40,7 +40,7 @@ export function Avatar(props: {
style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl}
onClick={onClick}
alt={username}
alt={`${username ?? 'Unknown user'} avatar`}
onError={() => {
// If the image doesn't load, clear the avatarUrl to show the default
// Mostly for localhost, when getting a 403 from googleusercontent

View File

@ -11,22 +11,16 @@ import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
replyTo?: { 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
onSubmitComment?: (editor: Editor) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
props
const user = useUser()
const { editor, upload } = useTextEditor({
@ -40,10 +34,10 @@ export function CommentInput(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
async function submitComment() {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
onSubmitComment?.(editor)
setIsSubmitting(false)
}
@ -61,11 +55,10 @@ export function CommentInput(props: {
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
replyTo={replyTo}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
@ -74,28 +67,19 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
replyTo?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
submitComment: () => void
isSubmitting: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
replyToUser,
} = props
const { user, editor, upload, submitComment, isSubmitting, replyTo } = props
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
submitComment()
editor?.commands?.clearContent()
}
@ -123,12 +107,12 @@ export function CommentInputTextArea(props: {
},
})
// insert at mention and focus
if (replyToUser) {
if (replyTo) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
attrs: { label: replyTo.username, id: replyTo.id },
})
.insertContent(' ')
.focus()
@ -142,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
@ -151,14 +135,14 @@ export function CommentInputTextArea(props: {
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
<LoadingIndicator spinnerClassName="border-gray-500" />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
className="btn btn-outline btn-sm mt-2 normal-case"
onClick={submitComment}
>
Add my comment
</button>

View File

@ -9,7 +9,14 @@ import {
} from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
import {
useEffect,
useLayoutEffect,
useRef,
useMemo,
ReactNode,
useState,
} from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows'
import {
@ -32,22 +39,26 @@ import {
searchClient,
searchIndexName,
} from 'web/lib/service/algolia'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button'
import { Modal } from './layout/modal'
import { Title } from './title'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: `Most traded`, value: 'most-traded' },
{ label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const
export type Sort = typeof SORTS[number]['value']
export const PROB_SORTS = ['prob-descending', 'prob-ascending']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
@ -78,11 +89,13 @@ export function ContractSearch(props: {
hideGroupLink?: boolean
hideQuickBet?: boolean
noLinkAvatar?: boolean
showProbChange?: boolean
}
headerClassName?: string
persistPrefix?: string
useQueryUrlParam?: boolean
isWholePage?: boolean
includeProbSorts?: boolean
noControls?: boolean
maxResults?: number
renderContracts?: (
@ -104,6 +117,7 @@ export function ContractSearch(props: {
headerClassName,
persistPrefix,
useQueryUrlParam,
includeProbSorts,
isWholePage,
noControls,
maxResults,
@ -116,6 +130,7 @@ export function ContractSearch(props: {
numPages: 1,
pages: [] as Contract[][],
showTime: null as ShowTime | null,
showProbChange: false,
},
!persistPrefix
? undefined
@ -169,8 +184,9 @@ export function ContractSearch(props: {
const newPage = results.hits as any as Contract[]
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : null
const showProbChange = sort === 'daily-score'
const pages = freshQuery ? [newPage] : [...state.pages, newPage]
setState({ numPages: results.nbPages, pages, showTime })
setState({ numPages: results.nbPages, pages, showTime, showProbChange })
if (freshQuery && isWholePage) window.scrollTo(0, 0)
}
}
@ -188,6 +204,12 @@ export function ContractSearch(props: {
}, 100)
).current
const updatedCardUIOptions = useMemo(() => {
if (cardUIOptions?.showProbChange === undefined && state.showProbChange)
return { ...cardUIOptions, showProbChange: true }
return cardUIOptions
}, [cardUIOptions, state.showProbChange])
const contracts = state.pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
@ -209,6 +231,7 @@ export function ContractSearch(props: {
persistPrefix={persistPrefix}
hideOrderSelector={hideOrderSelector}
useQueryUrlParam={useQueryUrlParam}
includeProbSorts={includeProbSorts}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
@ -223,7 +246,7 @@ export function ContractSearch(props: {
showTime={state.showTime ?? undefined}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
cardUIOptions={cardUIOptions}
cardUIOptions={updatedCardUIOptions}
/>
)}
</Col>
@ -238,6 +261,7 @@ function ContractSearchControls(props: {
additionalFilter?: AdditionalFilter
persistPrefix?: string
hideOrderSelector?: boolean
includeProbSorts?: boolean
onSearchParametersChanged: (params: SearchParameters) => void
useQueryUrlParam?: boolean
user?: User | null
@ -257,6 +281,7 @@ function ContractSearchControls(props: {
user,
noControls,
autoFocus,
includeProbSorts,
} = props
const router = useRouter()
@ -270,6 +295,8 @@ function ContractSearchControls(props: {
}
)
const isMobile = useIsMobile()
const sortKey = `${persistPrefix}-search-sort`
const savedSort = safeLocalStorage()?.getItem(sortKey)
@ -415,30 +442,33 @@ function ContractSearchControls(props: {
className="input input-bordered w-full"
autoFocus={autoFocus}
/>
{!query && (
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!isMobile && (
<SearchFilters
filter={filter}
selectFilter={selectFilter}
hideOrderSelector={hideOrderSelector}
selectSort={selectSort}
sort={sort}
className={'flex flex-row gap-2'}
includeProbSorts={includeProbSorts}
/>
)}
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{SORTS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isMobile && (
<>
<MobileSearchBar
children={
<SearchFilters
filter={filter}
selectFilter={selectFilter}
hideOrderSelector={hideOrderSelector}
selectSort={selectSort}
sort={sort}
className={'flex flex-col gap-4'}
includeProbSorts={includeProbSorts}
/>
}
/>
</>
)}
</Row>
@ -481,3 +511,78 @@ function ContractSearchControls(props: {
</Col>
)
}
export function SearchFilters(props: {
filter: string
selectFilter: (newFilter: filter) => void
hideOrderSelector: boolean | undefined
selectSort: (newSort: Sort) => void
sort: string
className?: string
includeProbSorts?: boolean
}) {
const {
filter,
selectFilter,
hideOrderSelector,
selectSort,
sort,
className,
includeProbSorts,
} = props
const sorts = includeProbSorts
? SORTS
: SORTS.filter((sort) => !PROB_SORTS.includes(sort.value))
return (
<div className={className}>
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sorts.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</div>
)
}
export function MobileSearchBar(props: { children: ReactNode }) {
const { children } = props
const [openFilters, setOpenFilters] = useState(false)
return (
<>
<Button color="gray-white" onClick={() => setOpenFilters(true)}>
<AdjustmentsIcon className="my-auto h-7" />
</Button>
<Modal
open={openFilters}
setOpen={setOpenFilters}
position="top"
className="rounded-lg bg-white px-4 pb-4"
>
<Col>
<Title text="Filter Markets" />
{children}
</Col>
</Modal>
</>
)
}

View File

@ -7,6 +7,7 @@ import { Col } from '../layout/col'
import {
BinaryContract,
Contract,
CPMMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
@ -32,6 +33,8 @@ import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip'
import { SiteLink } from '../site-link'
import { ProbChange } from './prob-change-table'
export function ContractCard(props: {
contract: Contract
@ -379,3 +382,34 @@ export function PseudoNumericResolutionOrExpectation(props: {
</Col>
)
}
export function ContractCardProbChange(props: {
contract: CPMMBinaryContract
noLinkAvatar?: boolean
className?: string
}) {
const { contract, noLinkAvatar, className } = props
return (
<Col
className={clsx(
className,
'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
)}
>
<AvatarDetails
contract={contract}
className={'px-6 pt-4'}
noLink={noLinkAvatar}
/>
<Row className={clsx('items-start justify-between gap-4 ', className)}>
<SiteLink
className="pl-6 pr-0 pt-2 pb-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-3">{contract.question}</span>
</SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} />
</Row>
</Col>
)
}

View File

@ -1,12 +1,10 @@
import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { listUsers, User } from 'web/lib/firebase/users'
import { memo } from 'react'
import { useComments } from 'web/hooks/use-comments'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer'
@ -14,62 +12,48 @@ import { Leaderboard } from '../leaderboard'
import { Title } from '../title'
import { BETTORS } from 'common/user'
export function ContractLeaderboard(props: {
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) => {
return {
name: bets[0].userName,
username: bets[0].userUsername,
avatarUrl: bets[0].userAvatarUrl,
total: sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount),
}
}, [userProfits, top5Ids])
})
// Find the 5 users with the most profits
const top5 = Object.values(userProfits)
.sort((p1, p2) => p2.total - p1.total)
.filter((p) => p.total > 0)
.slice(0, 5)
return users && users.length > 0 ? (
return top5 && top5.length > 0 ? (
<Leaderboard
title={`🏅 Top ${BETTORS}`}
users={users || []}
entries={top5 || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
renderCell: (entry) => formatMoney(entry.total),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
})
export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
// todo: this stuff should be calced in DB at resolve time
const comments = useComments(contract.id)
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
@ -90,30 +74,23 @@ export function ContractTopTrades(props: {
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
// And also the comment with the highest profit
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
{topComment && profitById[topComment.id] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
/>
<FeedComment contract={contract} comment={topComment} />
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
{topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 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">

View File

@ -47,14 +47,14 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1])
const quartiles = [0, 25, 50, 75, 100]
const { width } = useWindowSize()
const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100]
const yTickValues = isBinary
? quartiles
: quartiles.map((x) => x / 100).map(f)
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
const startDate = dayjs(times[0])
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
@ -104,7 +104,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
return (
<div
className="w-full overflow-visible"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }}
>
<ResponsiveLine
data={data}
@ -144,7 +144,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableGridX={false}
enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}

View File

@ -1,23 +1,23 @@
import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy } from 'lodash'
import { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { PAST_BETS, User } from 'common/user'
import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user'
import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { capitalize } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -25,88 +25,13 @@ import {
} from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: {
contract: Contract
user: User | null | undefined
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, user, bets, tips } = props
const { outcomeType } = contract
export function ContractTabs(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const isMobile = useIsMobile()
const lps = useLiquidity(contract.id)
const user = useUser()
const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = (lps ?? []).filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const betActivity = lps != null && (
<ContractBetsActivity
contract={contract}
bets={visibleBets}
lps={visibleLps}
/>
)
const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets
const generalComments = comments.filter(
(comment) =>
comment.answerOutcome === undefined &&
(outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true)
)
const commentActivity =
outcomeType === 'FREE_RESPONSE' ? (
<>
<FreeResponseContractCommentsActivity
contract={contract}
betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments}
tips={tips}
user={user}
/>
<Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} />
<ContractCommentsActivity
contract={contract}
betsByCurrentUser={
user ? generalBets.filter((b) => b.userId === user.id) : []
}
comments={generalComments}
tips={tips}
user={user}
/>
</Col>
</>
) : (
<ContractCommentsActivity
contract={contract}
betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments}
tips={tips}
user={user}
/>
)
const yourTrades = (
<div>
@ -123,44 +48,173 @@ export function ContractTabs(props: {
)
return (
<>
<Tabs
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: commentActivity,
badge: `${comments.length}`,
},
{
title: capitalize(PAST_BETS),
content: betActivity,
badge: `${visibleBets.length + visibleLps.length}`,
},
...(!user || !userBets?.length
? []
: [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]}
/>
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</>
<Tabs
className="mb-4"
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: <CommentsTabContent contract={contract} />,
},
{
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
...(!user || !userBets?.length
? []
: [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]}
/>
)
}
const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract
}) {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id)
if (comments == null) {
return <LoadingIndicator />
}
if (contract.outcomeType === 'FREE_RESPONSE') {
const generalComments = comments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{sortedAnswers.map((answer) => (
<div key={answer.id} className="relative pb-4">
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentInput className="mb-5" contract={contract} />
{generalComments.map((comment) => (
<FeedCommentThread
key={comment.id}
contract={contract}
parentComment={comment}
threadComments={[]}
tips={tips}
/>
))}
</Col>
</>
)
} else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = commentsByParent['_'] ?? []
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
})
const BetsTabContent = memo(function BetsTabContent(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? []
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = lps.filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
const items = [
...visibleBets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...visibleLps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const pageItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
).slice(start, end)
return (
<>
<Col className="mb-4 gap-4">
{pageItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
})

View File

@ -2,7 +2,7 @@ import { Contract } from 'web/lib/firebase/contracts'
import { User } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
import { ContractCard, ContractCardProbChange } from './contract-card'
import { ShowTime } from './contract-details'
import { ContractSearch } from '../contract-search'
import { useCallback } from 'react'
@ -10,6 +10,7 @@ import clsx from 'clsx'
import { LoadingIndicator } from '../loading-indicator'
import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract'
export type ContractHighlightOptions = {
contractIds?: string[]
@ -25,6 +26,7 @@ export function ContractsGrid(props: {
hideQuickBet?: boolean
hideGroupLink?: boolean
noLinkAvatar?: boolean
showProbChange?: boolean
}
highlightOptions?: ContractHighlightOptions
trackingPostfix?: string
@ -39,7 +41,8 @@ export function ContractsGrid(props: {
highlightOptions,
trackingPostfix,
} = props
const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback(
(visible) => {
@ -73,24 +76,31 @@ export function ContractsGrid(props: {
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{contracts.map((contract) => (
<ContractCard
contract={contract}
key={contract.id}
showTime={showTime}
onClick={
onContractClick ? () => onContractClick(contract) : undefined
}
noLinkAvatar={noLinkAvatar}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
className={clsx(
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
contractIds?.includes(contract.id) && highlightClassName
)}
/>
))}
{contracts.map((contract) =>
showProbChange && contract.mechanism === 'cpmm-1' ? (
<ContractCardProbChange
key={contract.id}
contract={contract as CPMMBinaryContract}
/>
) : (
<ContractCard
contract={contract}
key={contract.id}
showTime={showTime}
onClick={
onContractClick ? () => onContractClick(contract) : undefined
}
noLinkAvatar={noLinkAvatar}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
className={clsx(
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
contractIds?.includes(contract.id) && highlightClassName
)}
/>
)
)}
</Masonry>
{loadMore && (
<VisibilityObserver

View File

@ -1,6 +1,4 @@
import clsx from 'clsx'
import { ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts'
import React, { useState } from 'react'
@ -10,7 +8,7 @@ import { ShareModal } from './share-modal'
import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props
@ -23,27 +21,23 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Button
size="sm"
color="gray-white"
className={'flex'}
onClick={() => {
setShareOpen(true)
}}
>
<Row>
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
</Row>
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
contract={contract}
user={user}
/>
</Button>
<Col className={'justify-center'}>
<ContractInfoDialog contract={contract} />
</Col>
<Tooltip text="Share" placement="bottom" noTap noFade>
<Button
size="sm"
color="gray-white"
className={'flex'}
onClick={() => setShareOpen(true)}
>
<ShareIcon className="h-5 w-5" aria-hidden />
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
contract={contract}
user={user}
/>
</Button>
</Tooltip>
<ContractInfoDialog contract={contract} />
</Row>
)
}

View File

@ -13,6 +13,7 @@ import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash'
import { Tooltip } from '../tooltip'
export function LikeMarketButton(props: {
contract: Contract
@ -37,37 +38,44 @@ export function LikeMarketButton(props: {
}
return (
<Button
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
<Tooltip
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`}
placement="bottom"
noTap
noFade
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
/>
{totalTipped > 0 && (
<div
<Button
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
? 'fill-red-500 text-red-500'
: ''
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
)
}

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { partition } from 'lodash'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
@ -8,16 +9,17 @@ import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
export function ProbChangeTable(props: {
changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
changes: CPMMContract[] | undefined
full?: boolean
}) {
const { changes, full } = props
if (!changes) return <LoadingIndicator />
const { positiveChanges, negativeChanges } = changes
const [positiveChanges, negativeChanges] = partition(
changes,
(c) => c.probChanges.day > 0
)
const threshold = 0.01
const positiveAboveThreshold = positiveChanges.filter(
@ -53,10 +55,18 @@ export function ProbChangeTable(props: {
)
}
function ProbChangeRow(props: { contract: CPMMContract }) {
const { contract } = props
export function ProbChangeRow(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
return (
<Row className="items-center justify-between gap-4 hover:bg-gray-100">
<Row
className={clsx(
'items-center justify-between gap-4 hover:bg-gray-100',
className
)}
>
<SiteLink
className="p-4 pr-0 font-semibold text-indigo-700"
href={contractPath(contract)}

View File

@ -21,6 +21,7 @@ import { CreateChallengeModal } from 'web/components/challenges/create-challenge
import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
import { QRCode } from '../qr-code'
export function ShareModal(props: {
contract: Contract
@ -54,6 +55,12 @@ export function ShareModal(props: {
</SiteLink>{' '}
if a new user signs up using the link!
</p>
<QRCode
url={shareUrl}
className="self-center"
width={150}
height={150}
/>
<Button
size="2xl"
color="indigo"

View File

@ -0,0 +1,94 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group'
export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { group } = props
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
groupId: group?.id,
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
)
}

View File

@ -1,158 +0,0 @@
import { useState } from 'react'
import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { groupBy, sortBy } from 'lodash'
import { Col } from 'web/components/layout/col'
export function ContractBetsActivity(props: {
contract: Contract
bets: Bet[]
lps: LiquidityProvision[]
}) {
const { contract, bets, lps } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const items = [
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...lps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const pageItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
).slice(start, end)
return (
<>
<Col className="mb-4 gap-4">
{pageItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
}
export function ContractCommentsActivity(props: {
contract: Contract
betsByCurrentUser: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { betsByCurrentUser, contract, 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 (
<>
<ContractCommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/>
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
user={user}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}
export function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
betsByCurrentUser: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { betsByCurrentUser, contract, comments, user, tips } = props
const sortedAnswers = sortBy(
contract.answers,
(answer) => -getOutcomeProbability(contract, answer.number.toString())
)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{sortedAnswers.map((answer) => (
<div key={answer.id} className={'relative pb-4'}>
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
user={user}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId}
/>
</div>
))}
</>
)
}

View File

@ -1,8 +1,7 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
@ -11,109 +10,42 @@ import clsx from 'clsx'
import {
ContractCommentInput,
FeedComment,
getMostRecentCommentableBet,
ReplyTo,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
import { Dictionary } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link'
export function FeedAnswerCommentGroup(props: {
contract: FreeResponseContract
user: User | undefined | null
answer: Answer
answerComments: ContractComment[]
tips: CommentTipMap
betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
answer,
contract,
answerComments,
tips,
betsByCurrentUser,
commentsByUserId,
user,
} = props
const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] =
useState<Pick<User, 'id' | 'username'>>()
const [showReply, setShowReply] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const [replyTo, setReplyTo] = useState<ReplyTo>()
const router = useRouter()
const answerElementId = `answer-${answer.id}`
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
answer.number.toString()
)
const [usersMostRecentBetTimeAtLoad, setUsersMostRecentBetTimeAtLoad] =
useState<number | undefined>(
!user ? undefined : mostRecentCommentableBet?.createdTime ?? 0
)
const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const answerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (user && usersMostRecentBetTimeAtLoad === undefined)
setUsersMostRecentBetTimeAtLoad(
mostRecentCommentableBet?.createdTime ?? 0
)
}, [
mostRecentCommentableBet?.createdTime,
user,
usersMostRecentBetTimeAtLoad,
])
const scrollAndOpenReplyInput = useEvent(
(comment?: ContractComment, answer?: Answer) => {
setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }
: answer
? { id: answer.userId, username: answer.username }
: undefined
)
setShowReply(true)
if (highlighted && answerRef.current != null) {
answerRef.current.scrollIntoView(true)
}
)
useEffect(() => {
// Only show one comment input for a bet at a time
if (
betsByCurrentUser.length > 1 &&
// inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString()
)
setShowReply(false)
// Even if we pass memoized bets this still runs on every render, which we don't want
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser.length, user, answer.number])
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
}
}, [answerElementId, router.asPath])
}, [highlighted])
return (
<Col
className={'relative flex-1 items-stretch gap-3'}
key={answer.id + 'comment'}
>
<Col className="relative flex-1 items-stretch gap-3">
<Row
className={clsx(
'gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)}
ref={answerRef}
id={answerElementId}
>
<Avatar username={username} avatarUrl={avatarUrl} />
@ -133,28 +65,27 @@ export function FeedAnswerCommentGroup(props: {
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
)}
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
<div className="sm:hidden">
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
)}
</Col>
<div className="justify-initial hidden sm:block">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
</Col>
</Row>
<Col className="gap-3 pl-1">
@ -164,24 +95,24 @@ export function FeedAnswerCommentGroup(props: {
indent={true}
contract={contract}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
tips={tips[comment.id] ?? {}}
onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
/>
))}
</Col>
{showReply && (
<div className={'relative ml-7'}>
{replyTo && (
<div className="relative ml-7">
<span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()}
replyToUser={replyToUser}
onSubmitComment={() => setShowReply(false)}
replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)}
/>
</div>
)}

View File

@ -1,9 +1,6 @@
import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react'
import { Dictionary } from 'lodash'
import React, { useEffect, useRef, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
@ -23,31 +20,16 @@ import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export type ReplyTo = { id: string; username: string }
export function FeedCommentThread(props: {
user: User | null | undefined
contract: Contract
threadComments: ContractComment[]
tips: CommentTipMap
parentComment: ContractComment
betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
user,
contract,
threadComments,
commentsByUserId,
betsByCurrentUser,
tips,
parentComment,
} = props
const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: ContractComment) {
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
const { contract, threadComments, tips, parentComment } = props
const [replyTo, setReplyTo] = useState<ReplyTo>()
return (
<Col className="relative w-full items-stretch gap-3 pb-4">
@ -61,11 +43,13 @@ export function FeedCommentThread(props: {
indent={commentIdx != 0}
contract={contract}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
tips={tips[comment.id] ?? {}}
onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
/>
))}
{showReply && (
{replyTo && (
<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"
@ -73,14 +57,9 @@ export function FeedCommentThread(props: {
/>
<ContractCommentInput
contract={contract}
betsByCurrentUser={(user && betsByCurrentUser) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => {
setShowReply(false)
}}
replyTo={replyTo}
onSubmitComment={() => setReplyTo(undefined)}
/>
</Col>
)}
@ -91,9 +70,9 @@ export function FeedCommentThread(props: {
export function FeedComment(props: {
contract: Contract
comment: ContractComment
tips: CommentTips
tips?: CommentTips
indent?: boolean
onReplyClick?: (comment: ContractComment) => void
onReplyClick?: () => void
}) {
const { contract, comment, tips, indent, onReplyClick } = props
const {
@ -115,16 +94,19 @@ export function FeedComment(props: {
money = formatMoney(Math.abs(comment.betAmount))
}
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`)
const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
if (highlighted && commentRef.current != null) {
commentRef.current.scrollIntoView(true)
}
}, [comment.id, router.asPath])
}, [highlighted])
return (
<Row
ref={commentRef}
id={comment.id}
className={clsx(
'relative',
@ -187,11 +169,11 @@ export function FeedComment(props: {
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{tips && <Tipper comment={comment} tips={tips} />}
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={() => onReplyClick(comment)}
onClick={onReplyClick}
>
Reply
</button>
@ -202,34 +184,6 @@ export function FeedComment(props: {
)
}
export function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
commentsByCurrentUser: ContractComment[],
user?: User | null,
answerOutcome?: string
) {
let sortedBetsByCurrentUser = betsByCurrentUser.sort(
(a, b) => b.createdTime - a.createdTime
)
if (answerOutcome) {
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
}
return sortedBetsByCurrentUser
.filter((bet) => {
if (
canCommentOnBet(bet, user) &&
!commentsByCurrentUser.some(
(comment) => comment.createdTime > bet.createdTime
)
) {
if (!answerOutcome) return true
return answerOutcome === bet.outcome
}
return false
})
.pop()
}
function CommentStatus(props: {
contract: Contract
outcome: string
@ -247,16 +201,14 @@ function CommentStatus(props: {
export function ContractCommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string }
replyTo?: ReplyTo
parentCommentId?: string
onSubmitComment?: () => void
}) {
const user = useUser()
async function onSubmitComment(editor: Editor, betId: string | undefined) {
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
@ -265,37 +217,19 @@ export function ContractCommentInput(props: {
props.contract.id,
editor.getJSON(),
user,
betId,
props.parentAnswerOutcome,
props.parentCommentId
)
props.onSubmitComment?.()
}
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<CommentInput
replyToUser={props.replyToUser}
replyTo={props.replyTo}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
)
}
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
}

View File

@ -14,6 +14,7 @@ import { track } from 'web/lib/service/analytics'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Tooltip } from './tooltip'
export const FollowMarketButton = (props: {
contract: Contract
@ -23,61 +24,70 @@ export const FollowMarketButton = (props: {
const followers = useContractFollows(contract.id)
const [open, setOpen] = useState(false)
const watching = followers?.includes(user?.id ?? 'nope')
return (
<Button
size={'sm'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
if (followers?.includes(user.id)) {
await unFollowContract(contract.id, user.id)
toast("You'll no longer receive notifications from this market", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Unwatch Market', {
slug: contract.slug,
})
} else {
await followContract(contract.id, user.id)
toast("You'll now receive notifications from this market!", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Watch Market', {
slug: contract.slug,
})
}
if (!user.hasSeenContractFollowModal) {
await updateUser(user.id, {
hasSeenContractFollowModal: true,
})
setOpen(true)
}
}}
<Tooltip
text={watching ? 'Unfollow' : 'Follow'}
placement="bottom"
noTap
noFade
>
{followers?.includes(user?.id ?? 'nope') ? (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Unwatch */}
</Col>
) : (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */}
</Col>
)}
<WatchMarketModal
open={open}
setOpen={setOpen}
title={`You ${
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`}
/>
</Button>
<Button
size={'sm'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
if (followers?.includes(user.id)) {
await unFollowContract(contract.id, user.id)
toast("You'll no longer receive notifications from this market", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Unwatch Market', {
slug: contract.slug,
})
} else {
await followContract(contract.id, user.id)
toast("You'll now receive notifications from this market!", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Watch Market', {
slug: contract.slug,
})
}
if (!user.hasSeenContractFollowModal) {
await updateUser(user.id, {
hasSeenContractFollowModal: true,
})
setOpen(true)
}
}}
>
{watching ? (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Unwatch */}
</Col>
) : (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */}
</Col>
)}
<WatchMarketModal
open={open}
setOpen={setOpen}
title={`You ${
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`}
/>
</Button>
</Tooltip>
)
}

View File

@ -115,6 +115,7 @@ function FollowsDialog(props: {
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs
className="mb-4"
tabs={[
{
title: 'Following',

View File

@ -16,29 +16,26 @@ import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: {
group: Group
isEditable: boolean
post: Post
post: Post | null
}) {
const { group, isEditable } = props
const post = usePost(group.aboutPostId) ?? props.post
return (
<div className="rounded-md bg-white p-4 ">
{isEditable ? (
<RichEditGroupAboutPost group={group} post={post} />
) : (
<Content content={post.content} />
)}
{isEditable && <RichEditGroupAboutPost group={group} post={post} />}
{!isEditable && post && <Content content={post.content} />}
</div>
)
}
function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
const { group, post } = props
const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
defaultValue: post.content,
defaultValue: post?.content,
disabled: isSubmitting,
})
@ -49,7 +46,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
content: editor.getJSON(),
}
if (group.aboutPostId == null) {
if (post == null) {
const result = await createPost(newPost).catch((e) => {
console.error(e)
return e
@ -65,6 +62,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
}
async function deleteGroupAboutPost() {
if (post == null) return
await deletePost(post)
await deleteFieldFromGroup(group, 'aboutPostId')
}
@ -91,7 +89,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
</>
) : (
<>
{group.aboutPostId == null ? (
{post == null ? (
<div className="text-center text-gray-500">
<p className="text-sm">
No post has been added yet.

View File

@ -23,6 +23,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
height={250}
width={250}
className="self-center"
alt="Manifold logo"
src="/flappy-logo.gif"
/>
<div className="m-4 max-w-[550px] self-center">

View File

@ -31,7 +31,7 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
return (
<>
<nav
className={clsx('mb-4 space-x-8 border-b border-gray-200', className)}
className={clsx('space-x-8 border-b border-gray-200', className)}
aria-label="Tabs"
>
{tabs.map((tab, i) => (

View File

@ -1,28 +1,33 @@
import clsx from 'clsx'
import { User } from 'common/user'
import { Avatar } from './avatar'
import { Row } from './layout/row'
import { SiteLink } from './site-link'
import { Title } from './title'
export function Leaderboard(props: {
interface LeaderboardEntry {
username: string
name: string
avatarUrl?: string
}
export function Leaderboard<T extends LeaderboardEntry>(props: {
title: string
users: User[]
entries: T[]
columns: {
header: string
renderCell: (user: User) => any
renderCell: (entry: T) => any
}[]
className?: string
maxToShow?: number
}) {
// TODO: Ideally, highlight your own entry on the leaderboard
const { title, columns, className } = props
const maxToShow = props.maxToShow ?? props.users.length
const users = props.users.slice(0, maxToShow)
const maxToShow = props.maxToShow ?? props.entries.length
const entries = props.entries.slice(0, maxToShow)
return (
<div className={clsx('w-full px-1', className)}>
<Title text={title} className="!mt-0" />
{users.length === 0 ? (
{entries.length === 0 ? (
<div className="ml-2 text-gray-500">None yet</div>
) : (
<div className="overflow-x-auto">
@ -37,19 +42,19 @@ export function Leaderboard(props: {
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={user.id}>
{entries.map((entry, index) => (
<tr key={index}>
<td>{index + 1}</td>
<td className="max-w-[190px]">
<SiteLink className="relative" href={`/${user.username}`}>
<SiteLink className="relative" href={`/${entry.username}`}>
<Row className="items-center gap-4">
<Avatar avatarUrl={user.avatarUrl} size={8} />
<div className="truncate">{user.name}</div>
<Avatar avatarUrl={entry.avatarUrl} size={8} />
<div className="truncate">{entry.name}</div>
</Row>
</SiteLink>
</td>
{columns.map((column) => (
<td key={column.header}>{column.renderCell(user)}</td>
<td key={column.header}>{column.renderCell(entry)}</td>
))}
</tr>
))}

View File

@ -14,6 +14,8 @@ import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin'
export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
@ -28,31 +30,32 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
setShowWithdrawal(true)
}, [showWithdrawal, lpShares])
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return (
<Tabs
tabs={[
{
title: 'Subsidize',
tabs={buildArray(
(isCreator || isAdmin) && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
...(showWithdrawal
? [
{
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
]
: []),
showWithdrawal && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
{
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
},
]}
}
)}
/>
)
}

View File

@ -1,94 +0,0 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar-item'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { useUser } from 'web/hooks/use-user'
import NotificationsIcon from '../notifications-icon'
import router from 'next/router'
import { userProfileItem } from './bottom-nav-bar'
const mobileGroupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
]
const mobileGeneralNavigation = [
{
name: 'Notifications',
key: 'notifications',
icon: NotificationsIcon,
href: '/notifications',
},
]
export function GroupNavBar(props: {
currentPage: string
onClick: (key: string) => void
}) {
const { currentPage } = props
const user = useUser()
return (
<nav className="z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={props.onClick}
/>
))}
{mobileGeneralNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={() => {
router.push(item.href)
}}
/>
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
onClick={() => {
router.push(`/${user.username}?tab=trades`)
}}
item={userProfileItem(user)}
/>
)}
</nav>
)
}
function NavBarItem(props: {
item: Item
currentPage: string
onClick: (key: string) => void
}) {
const { item, currentPage } = props
const track = trackCallback(
`group navbar: ${item.trackingEventName ?? item.name}`
)
return (
<button onClick={() => props.onClick(item.key ?? '#')}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.key && 'bg-gray-200 text-indigo-700'
)}
onClick={track}
>
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</button>
)
}

View File

@ -1,82 +0,0 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from './manifold-logo'
import { ProfileSummary } from './profile-menu'
import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar-item'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
const groupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const generalNavigation = (user?: User | null) =>
buildArray(
user && {
name: 'Notifications',
href: `/notifications`,
key: 'notifications',
icon: NotificationsIcon,
}
)
export function GroupSidebar(props: {
groupName: string
className?: string
onClick: (key: string) => void
joinOrAddQuestionsButton: React.ReactNode
currentKey: string
}) {
const { className, groupName, currentKey } = props
const user = useUser()
return (
<nav
aria-label="Group Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="pt-6" twoLine />
<Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row>
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
{user ? (
<ProfileSummary user={user} />
) : (
<SignInButton className="mb-4" />
)}
</div>
{/* Desktop navigation */}
{groupNavigation.map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{generalNavigation(user).map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
<Spacer h={2} />
{props.joinOrAddQuestionsButton}
</nav>
)
}

View File

@ -26,9 +26,14 @@ import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import { SidebarItem } from './sidebar-item'
import { MoreButton } from './more-button'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
export default function Sidebar(props: { className?: string }) {
const { className } = props
export default function Sidebar(props: {
className?: string
logoSubheading?: string
}) {
const { className, logoSubheading } = props
const router = useRouter()
const currentPage = router.pathname
@ -51,7 +56,13 @@ export default function Sidebar(props: { className?: string }) {
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
<ManifoldLogo className="pt-6" twoLine />
{logoSubheading && (
<Row className="pl-2 text-2xl text-indigo-700 sm:mt-3">
{logoSubheading}
</Row>
)}
<Spacer h={6} />
{!user && <SignInButton className="mb-4" />}

View File

@ -1,6 +1,9 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useState } from 'react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
@ -27,16 +30,12 @@ export default function Welcome() {
}
}
async function setUserHasSeenWelcome() {
if (user) {
await updateUser(user.id, { ['shouldShowWelcome']: false })
}
const setUserHasSeenWelcome = async () => {
if (user) await updateUser(user.id, { ['shouldShowWelcome']: false })
}
const [groupSelectorOpen, setGroupSelectorOpen] = useState(false)
if (!user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <></>
const toggleOpen = (isOpen: boolean) => {
setUserHasSeenWelcome()
setOpen(isOpen)
@ -45,6 +44,12 @@ export default function Welcome() {
setGroupSelectorOpen(true)
}
}
const isTwitch = useIsTwitch(user)
if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen))
return <></>
return (
<>
<GroupSelectorDialog
@ -89,6 +94,19 @@ export default function Welcome() {
)
}
const useIsTwitch = (user: User | null | undefined) => {
const router = useRouter()
const isTwitch = router.pathname === '/twitch'
useEffect(() => {
if (isTwitch && user?.shouldShowWelcome) {
updateUser(user.id, { ['shouldShowWelcome']: false })
}
}, [isTwitch, user])
return isTwitch
}
function PageIndicator(props: { page: number; totalpages: number }) {
const { page, totalpages } = props
return (

View File

@ -9,8 +9,15 @@ export function Page(props: {
className?: string
rightSidebarClassName?: string
children?: ReactNode
logoSubheading?: string
}) {
const { children, rightSidebar, className, rightSidebarClassName } = props
const {
children,
rightSidebar,
className,
rightSidebarClassName,
logoSubheading,
} = props
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
return (
@ -23,7 +30,10 @@ export function Page(props: {
)}
>
<Toaster />
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" />
<Sidebar
logoSubheading={logoSubheading}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
/>
<main
className={clsx(
'lg:col-span-8 lg:pt-6',

View File

@ -64,6 +64,7 @@ function ReferralsDialog(props: {
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs
className="mb-4"
tabs={[
{
title: 'Referrals',

View File

@ -16,6 +16,8 @@ import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Tooltip } from './tooltip'
const TIP_SIZE = 10
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop
@ -82,9 +84,12 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
return (
<Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-5) : undefined} />
<DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} />
<span className="font-bold">{Math.floor(total)}</span>
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} />
<UpTip
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined}
value={localTip}
/>
{localTip === 0 ? (
''
) : (
@ -107,7 +112,7 @@ function DownTip(props: { onClick?: () => void }) {
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `-${formatMoney(5)}`}
text={onClick && `-${formatMoney(TIP_SIZE)}`}
noTap
>
<button
@ -128,7 +133,7 @@ function UpTip(props: { onClick?: () => void; value: number }) {
<Tooltip
className="h-6 w-6"
placement="bottom"
text={onClick && `Tip ${formatMoney(5)}`}
text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
noTap
>
<button

View File

@ -254,6 +254,7 @@ export function UserPage(props: { user: User }) {
</Row>
)}
<QueryUncontrolledTabs
className="mb-4"
currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '}
tabs={[
@ -283,7 +284,7 @@ export function UserPage(props: { user: User }) {
title: 'Stats',
content: (
<Col className="mb-8">
<Row className={'mb-8 flex-wrap items-center gap-6'}>
<Row className="mb-8 flex-wrap items-center gap-x-6 gap-y-2">
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} />

View File

@ -9,12 +9,13 @@ import {
getUserBetContractsQuery,
listAllContracts,
trendingContractsQuery,
getContractsQuery,
} from 'web/lib/firebase/contracts'
import { QueryClient, useQueryClient } from 'react-query'
import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
import { query, limit } from 'firebase/firestore'
import { Sort } from 'web/components/contract-search'
import { dailyScoreIndex } from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -26,6 +27,29 @@ export const useContracts = () => {
return contracts
}
export const useContractsByDailyScoreGroups = (
groupSlugs: string[] | undefined
) => {
const facetFilters = ['isResolved:false']
const { data } = useQuery(['daily-score', groupSlugs], () =>
Promise.all(
(groupSlugs ?? []).map((slug) =>
dailyScoreIndex.search<CPMMBinaryContract>('', {
facetFilters: [...facetFilters, `groupLinks.slug:${slug}`],
})
)
)
)
if (!groupSlugs || !data || data.length !== groupSlugs.length)
return undefined
return zipObject(
groupSlugs,
data.map((d) => d.hits.filter((c) => c.dailyScore))
)
}
const q = new QueryClient()
export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
@ -40,19 +64,6 @@ export const useTrendingContracts = (maxContracts: number) => {
return result.data
}
export const useContractsQuery = (
sort: Sort,
maxContracts: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const result = useFirestoreQueryData(
['contracts-query', sort, maxContracts, filters],
getContractsQuery(sort, maxContracts, filters, visibility)
)
return result.data
}
export const useInactiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()

View File

@ -104,7 +104,7 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
}
export function useMemberGroupsSubscription(user: User | null | undefined) {
const cachedGroups = useMemberGroups(user?.id) ?? []
const cachedGroups = useMemberGroups(user?.id)
const [groups, setGroups] = useState(cachedGroups)
const userId = user?.id

View File

@ -11,3 +11,29 @@ export const usePost = (postId: string | undefined) => {
return post
}
export const usePosts = (postIds: string[]) => {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
if (postIds.length === 0) return
setPosts([])
const unsubscribes = postIds.map((postId) =>
listenForPost(postId, (post) => {
if (post) {
setPosts((posts) => [...posts, post])
}
})
)
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe())
}
}, [postIds])
return posts
.filter(
(post, index, self) => index === self.findIndex((t) => t.id === post.id)
)
.sort((a, b) => b.createdTime - a.createdTime)
}

View File

@ -1,75 +1,47 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { CPMMContract } from 'common/contract'
import { MINUTE_MS } from 'common/util/time'
import { useQuery, useQueryClient } from 'react-query'
import { CPMMBinaryContract } from 'common/contract'
import { sortBy, uniqBy } from 'lodash'
import { useQuery } from 'react-query'
import {
getProbChangesNegative,
getProbChangesPositive,
} from 'web/lib/firebase/contracts'
import { getValues } from 'web/lib/firebase/utils'
import { getIndexName, searchClient } from 'web/lib/service/algolia'
probChangeAscendingIndex,
probChangeDescendingIndex,
} from 'web/lib/service/algolia'
export const useProbChangesAlgolia = (userId: string) => {
const { data: positiveData } = useQuery(['prob-change-day', userId], () =>
searchClient
.initIndex(getIndexName('prob-change-day'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
const { data: negativeData } = useQuery(
['prob-change-day-ascending', userId],
() =>
searchClient
.initIndex(getIndexName('prob-change-day-ascending'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
export const useProbChanges = (
filters: { bettorId?: string; groupSlugs?: string[] } = {}
) => {
const { bettorId, groupSlugs } = filters
if (!positiveData || !negativeData) {
return undefined
const bettorFilter = bettorId ? `uniqueBettorIds:${bettorId}` : ''
const groupFilters = groupSlugs
? groupSlugs.map((slug) => `groupLinks.slug:${slug}`)
: []
const facetFilters = [
'isResolved:false',
'outcomeType:BINARY',
bettorFilter,
groupFilters,
]
const searchParams = {
facetFilters,
hitsPerPage: 50,
}
return {
positiveChanges: positiveData.hits
.filter((c) => c.probChanges && c.probChanges.day > 0)
.filter((c) => c.outcomeType === 'BINARY'),
negativeChanges: negativeData.hits
.filter((c) => c.probChanges && c.probChanges.day < 0)
.filter((c) => c.outcomeType === 'BINARY'),
}
}
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 }
}
export const usePrefetchProbChanges = (userId: string | undefined) => {
const queryClient = useQueryClient()
if (userId) {
queryClient.prefetchQuery(
['prob-changes-day-positive', userId],
() => getValues(getProbChangesPositive(userId)),
{ staleTime: MINUTE_MS }
)
queryClient.prefetchQuery(
['prob-changes-day-negative', userId],
() => getValues(getProbChangesNegative(userId)),
{ staleTime: MINUTE_MS }
)
}
const { data: positiveChanges } = useQuery(
['prob-change-day', groupSlugs],
() => probChangeDescendingIndex.search<CPMMBinaryContract>('', searchParams)
)
const { data: negativeChanges } = useQuery(
['prob-change-day-ascending', groupSlugs],
() => probChangeAscendingIndex.search<CPMMBinaryContract>('', searchParams)
)
if (!positiveChanges || !negativeChanges) return undefined
const hits = uniqBy(
[...positiveChanges.hits, ...negativeChanges.hits],
(c) => c.id
)
return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse()
}

View File

@ -90,6 +90,10 @@ export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
}
export function createPost(params: { title: string; content: JSONContent }) {
export function createPost(params: {
title: string
content: JSONContent
groupId?: string
}) {
return call(getFunctionUrl('createpost'), 'POST', params)
}

View File

@ -35,17 +35,13 @@ export async function createCommentOnContract(
contractId: string,
content: JSONContent,
user: User,
betId?: string,
answerOutcome?: string,
replyToCommentId?: string
) {
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const ref = doc(getCommentsCollection(contractId))
const onContract = {
commentType: 'contract',
contractId,
betId,
answerOutcome,
} as OnContract
return await createComment(

View File

@ -16,7 +16,7 @@ import {
import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
import { BinaryContract, Contract } from 'common/contract'
import { chooseRandomSubset } from 'common/util/random'
import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
@ -24,7 +24,6 @@ import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants'
import { getBinaryProb } from 'common/contract-details'
import { Sort } from 'web/components/contract-search'
export const contracts = coll<Contract>('contracts')
@ -321,51 +320,6 @@ export const getTopGroupContracts = async (
return await getValues<Contract>(creatorContractsQuery)
}
const sortToField = {
newest: 'createdTime',
score: 'popularityScore',
'most-traded': 'volume',
'24-hour-vol': 'volume24Hours',
'prob-change-day': 'probChanges.day',
'last-updated': 'lastUpdated',
liquidity: 'totalLiquidity',
'close-date': 'closeTime',
'resolve-date': 'resolutionTime',
'prob-descending': 'prob',
'prob-ascending': 'prob',
} as const
const sortToDirection = {
newest: 'desc',
score: 'desc',
'most-traded': 'desc',
'24-hour-vol': 'desc',
'prob-change-day': 'desc',
'last-updated': 'desc',
liquidity: 'desc',
'close-date': 'asc',
'resolve-date': 'desc',
'prob-ascending': 'asc',
'prob-descending': 'desc',
} as const
export const getContractsQuery = (
sort: Sort,
maxItems: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const { groupSlug } = filters
return query(
contracts,
where('isResolved', '==', false),
...(visibility ? [where('visibility', '==', visibility)] : []),
...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []),
orderBy(sortToField[sort], sortToDirection[sort]),
limit(maxItems)
)
}
export const getRecommendedContracts = async (
contract: Contract,
excludeBettorId: string,
@ -426,21 +380,3 @@ export async function getRecentBetsAndComments(contract: Contract) {
recentComments,
}
}
export const getProbChangesPositive = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '>', 0),
orderBy('probChanges.day', 'desc'),
limit(10)
) as Query<CPMMContract>
export const getProbChangesNegative = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '<', 0),
orderBy('probChanges.day', 'asc'),
limit(10)
) as Query<CPMMContract>

View File

@ -43,6 +43,7 @@ export function groupPath(
| 'about'
| typeof GROUP_CHAT_SLUG
| 'leaderboards'
| 'posts'
) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}

View File

@ -39,3 +39,8 @@ export function listenForPost(
) {
return listenForValue(doc(posts, postId), setPost)
}
export async function listPosts(postIds?: string[]) {
if (postIds === undefined) return []
return Promise.all(postIds.map(getPost))
}

View File

@ -13,3 +13,13 @@ export const searchIndexName =
export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}`
}
export const probChangeDescendingIndex = searchClient.initIndex(
getIndexName('prob-change-day')
)
export const probChangeAscendingIndex = searchClient.initIndex(
getIndexName('prob-change-day-ascending')
)
export const dailyScoreIndex = searchClient.initIndex(
getIndexName('daily-score')
)

View File

@ -46,7 +46,7 @@
"gridjs-react": "5.0.2",
"lodash": "4.17.21",
"nanoid": "^3.3.4",
"next": "12.2.5",
"next": "12.3.1",
"node-fetch": "3.2.4",
"prosemirror-state": "1.4.1",
"react": "17.0.2",

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { memo, useEffect, useMemo, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { useContractWithPreload } from 'web/hooks/use-contract'
@ -17,7 +17,6 @@ import {
import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page'
import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
@ -31,10 +30,7 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description'
import {
@ -45,31 +41,24 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
import BetButton from 'web/components/bet-button'
import dayjs from 'dayjs'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
params: { username: string; contractSlug: string }
}) {
const { username, contractSlug } = props.params
const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const [bets, comments] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
])
const bets = contractId ? await listAllBets(contractId) : []
return {
props: {
contract,
username,
slug: contractSlug,
// Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
},
// Limit the data sent to the client. Client will still load all bets directly.
props: { contract, bets: bets.slice(0, 5000) },
revalidate: 5, // regenerate after five seconds
}
}
@ -80,21 +69,11 @@ export async function getStaticPaths() {
export default function ContractPage(props: {
contract: Contract | null
username: string
bets: Bet[]
comments: ContractComment[]
slug: string
backToHome?: () => void
}) {
props = usePropz(props, getStaticPropz) ?? {
contract: null,
username: '',
comments: [],
bets: [],
slug: '',
}
props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
const user = useUser()
const inIframe = useIsIframe()
if (inIframe) {
return <ContractEmbedPage {...props} />
@ -106,9 +85,7 @@ export default function ContractPage(props: {
return <Custom404 />
}
return (
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
)
return <ContractPageContent key={contract.id} {...{ ...props, contract }} />
}
// requires an admin to resolve a week after market closes
@ -116,12 +93,10 @@ export function needsAdminToResolve(contract: Contract) {
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
}
export function ContractPageSidebar(props: {
user: User | null | undefined
contract: Contract
}) {
const { contract, user } = props
export function ContractPageSidebar(props: { contract: Contract }) {
const { contract } = props
const { creatorId, isResolved, outcomeType } = contract
const user = useUser()
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
@ -170,11 +145,11 @@ export function ContractPageSidebar(props: {
export function ContractPageContent(
props: Parameters<typeof ContractPage>[0] & {
contract: Contract
user?: User | null
}
) {
const { backToHome, comments, user } = props
const { backToHome } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const user = useUser()
usePrefetch(user?.id)
useTracking(
'view market',
@ -192,8 +167,6 @@ export function ContractPageContent(
[bets]
)
const tips = useTipTxns({ contractId: contract.id })
const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => {
@ -205,18 +178,6 @@ export function ContractPageContent(
setShowConfetti(shouldSeeConfetti)
}, [contract, user])
const [recommendedContracts, setRecommendedContracts] = useState<Contract[]>(
[]
)
useEffect(() => {
if (contract && user) {
getRecommendedContracts(contract, user.id, 6).then(
setRecommendedContracts
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, user?.id])
const { isResolved, question, outcomeType } = contract
const allowTrade = tradingAllowed(contract)
@ -228,9 +189,8 @@ export function ContractPageContent(
contractId: contract.id,
})
const rightSidebar = <ContractPageSidebar user={user} contract={contract} />
return (
<Page rightSidebar={rightSidebar}>
<Page rightSidebar={<ContractPageSidebar contract={contract} />}>
{showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
)}
@ -239,7 +199,7 @@ export function ContractPageContent(
<SEO
title={question}
description={ogCardProps.description}
url={`/${props.username}/${props.slug}`}
url={`/${contract.creatorUsername}/${contract.slug}`}
ogCardProps={ogCardProps}
/>
)}
@ -282,35 +242,55 @@ export function ContractPageContent(
<>
<div className="grid grid-cols-1 sm:grid-cols-2">
<ContractLeaderboard contract={contract} bets={bets} />
<ContractTopTrades
contract={contract}
bets={bets}
comments={comments}
tips={tips}
/>
<ContractTopTrades contract={contract} bets={bets} />
</div>
<Spacer h={12} />
</>
)}
<ContractTabs
contract={contract}
user={user}
bets={bets}
tips={tips}
comments={comments}
/>
<ContractTabs contract={contract} bets={bets} />
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
allowTrade && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</Col>
{recommendedContracts.length > 0 && (
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendedContracts}
trackingPostfix=" recommended"
/>
</Col>
)}
<RecommendedContractsWidget contract={contract} />
</Page>
)
}
const RecommendedContractsWidget = memo(
function RecommendedContractsWidget(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const [recommendations, setRecommendations] = useState<Contract[]>([])
useEffect(() => {
if (user) {
getRecommendedContracts(contract, user.id, 6).then(setRecommendations)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, user?.id])
if (recommendations.length === 0) {
return null
}
return (
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendations}
trackingPostfix=" recommended"
/>
</Col>
)
}
)

View File

@ -4,7 +4,7 @@ import { useEffect } from 'react'
import Head from 'next/head'
import Script from 'next/script'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
import { AuthProvider, AuthUser } from 'web/components/auth-context'
import Welcome from 'web/components/onboarding/welcome'
function firstLine(msg: string) {
@ -24,7 +24,10 @@ function printBuildInfo() {
}
}
function MyApp({ Component, pageProps }: AppProps) {
// specially treated props that may be present in the server/static props
type ManifoldPageProps = { auth?: AuthUser }
function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
useEffect(printBuildInfo, [])
return (
@ -78,7 +81,7 @@ function MyApp({ Component, pageProps }: AppProps) {
</Head>
<AuthProvider serverUser={pageProps.auth}>
<QueryClientProvider client={queryClient}>
<Welcome {...pageProps} />
<Welcome />
<Component {...pageProps} />
</QueryClientProvider>
</AuthProvider>

View File

@ -24,14 +24,14 @@ export default function AddFundsPage() {
return (
<Page>
<SEO
title="Get Manifold Dollars"
description="Get Manifold Dollars"
title="Get Mana"
description="Buy mana to trade in your favorite markets on Manifold"
url="/add-funds"
/>
<Col className="items-center">
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<Title className="!mt-0" text="Get Manifold Dollars" />
<Title className="!mt-0" text="Get Mana" />
<img
className="mb-6 block -scale-x-100 self-center"
src="/stylized-crane-black.png"
@ -40,8 +40,8 @@ export default function AddFundsPage() {
/>
<div className="mb-6 text-gray-500">
Purchase Manifold Dollars to trade in your favorite markets. <br />{' '}
(Not redeemable for cash.)
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>

View File

@ -0,0 +1,28 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
} from 'common/envs/constants'
import { applyCorsHeaders } from 'web/lib/api/cors'
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
export const config = { api: { bodyParser: true } }
export default async function route(req: NextApiRequest, res: NextApiResponse) {
await applyCorsHeaders(req, res, {
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: 'POST',
})
const { id } = req.query
const contractId = id as string
if (req.body) req.body.contractId = contractId
try {
const backendRes = await fetchBackend(req, 'closemarket')
await forwardResponse(res, backendRes)
} catch (err) {
console.error('Error talking to cloud function: ', err)
res.status(500).json({ message: 'Error communicating with backend.' })
}
}

View File

@ -92,7 +92,7 @@ export default function ChallengesListPage() {
tap the button above to create a new market & challenge in one.
</p>
<Tabs tabs={[...userTab, ...publicTab]} />
<Tabs className="mb-4" tabs={[...userTab, ...publicTab]} />
</Col>
</Page>
)

View File

@ -1,93 +0,0 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
export default function CreatePost() {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<Page>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
</Page>
)
}

View File

@ -2,13 +2,19 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { Col } from 'web/components/layout/col'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
export default function DailyMovers() {
const user = useUser()
const bettorId = user?.id ?? undefined
const changes = useProbChangesAlgolia(user?.id ?? '')
const changes = useProbChanges({ bettorId })?.filter(
(c) => Math.abs(c.probChanges.day) >= 0.01
)
useTracking('view daily movers')
return (
<Page>

View File

@ -34,20 +34,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
params: { username: string; contractSlug: string }
}) {
const { username, contractSlug } = props.params
const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : []
return {
props: {
contract,
username,
slug: contractSlug,
bets,
},
props: { contract, bets },
revalidate: 60, // regenerate after a minute
}
}
@ -58,16 +52,9 @@ export async function getStaticPaths() {
export default function ContractEmbedPage(props: {
contract: Contract | null
username: string
bets: Bet[]
slug: string
}) {
props = usePropz(props, getStaticPropz) ?? {
contract: null,
username: '',
bets: [],
slug: '',
}
props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
const contract = useContractWithPreload(props.contract)
const { bets } = props

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { toast, Toaster } from 'react-hot-toast'
import { toast } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
@ -16,7 +16,7 @@ import {
import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { useUser, useUserById } from 'web/hooks/use-user'
import {
useGroup,
useGroupContractIds,
@ -42,17 +42,21 @@ import { GroupComment } from 'common/comment'
import { REFERRAL_AMOUNT } from 'common/economy'
import { UserLink } from 'web/components/user-link'
import { GroupAboutPost } from 'web/components/groups/group-about-post'
import { getPost } from 'web/lib/firebase/posts'
import { getPost, listPosts, postPath } from 'web/lib/firebase/posts'
import { Post } from 'common/post'
import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { usePost, usePosts } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
import { ArrowLeftIcon } from '@heroicons/react/solid'
import { GroupSidebar } from 'web/components/nav/group-sidebar'
import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user'
import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs'
import { Avatar } from 'web/components/avatar'
import { Title } from 'web/components/title'
import { fromNow } from 'web/lib/util/time'
import { CreatePost } from 'web/components/create-post'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -70,7 +74,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
? 'all'
: 'open'
const aboutPost =
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
group && group.aboutPostId != null ? await getPost(group.aboutPostId) : null
const messages = group && (await listAllCommentsOnGroup(group.id))
const cachedTopTraderIds =
@ -83,6 +88,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const creator = await creatorPromise
const posts = ((group && (await listPosts(group.postIds))) ?? []).filter(
(p) => p != null
) as Post[]
return {
props: {
group,
@ -93,6 +101,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
messages,
aboutPost,
suggestedFilter,
posts,
},
revalidate: 60, // regenerate after a minute
@ -107,6 +116,7 @@ const groupSubpages = [
'markets',
'leaderboards',
'about',
'posts',
] as const
export default function GroupPage(props: {
@ -116,8 +126,9 @@ export default function GroupPage(props: {
topTraders: { user: User; score: number }[]
topCreators: { user: User; score: number }[]
messages: GroupComment[]
aboutPost: Post
aboutPost: Post | null
suggestedFilter: 'open' | 'all'
posts: Post[]
}) {
props = usePropz(props, getStaticPropz) ?? {
group: null,
@ -127,8 +138,9 @@ export default function GroupPage(props: {
topCreators: [],
messages: [],
suggestedFilter: 'open',
posts: [],
}
const { creator, topTraders, topCreators, suggestedFilter } = props
const { creator, topTraders, topCreators, suggestedFilter, posts } = props
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
@ -137,13 +149,15 @@ export default function GroupPage(props: {
const group = useGroup(props.group?.id) ?? props.group
const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost
let groupPosts = usePosts(group?.postIds ?? []) ?? posts
if (aboutPost != null) {
groupPosts = [aboutPost, ...groupPosts]
}
const user = useUser()
const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
// Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -157,7 +171,7 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50
const leaderboardPage = (
const leaderboardTab = (
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard
@ -176,7 +190,17 @@ export default function GroupPage(props: {
</Col>
)
const aboutPage = (
const postsPage = (
<>
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{posts && <GroupPosts posts={groupPosts} group={group} />}
</div>
</Col>
</>
)
const aboutTab = (
<Col>
{(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost
@ -196,16 +220,21 @@ export default function GroupPage(props: {
</Col>
)
const questionsPage = (
const questionsTab = (
<>
{/* align the divs to the right */}
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
<div>
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
<div className={'flex justify-end '}>
<div
className={
'flex items-end justify-self-end px-2 md:absolute md:top-0 md:pb-2'
}
>
<div>
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</div>
</div>
<ContractSearch
@ -215,92 +244,46 @@ export default function GroupPage(props: {
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
includeProbSorts
/>
</>
)
const sidebarPages = [
const tabs = [
{
title: 'Markets',
content: questionsPage,
href: groupPath(group.slug, 'markets'),
key: 'markets',
content: questionsTab,
},
{
title: 'Leaderboards',
content: leaderboardPage,
href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
content: leaderboardTab,
},
{
title: 'About',
content: aboutPage,
href: groupPath(group.slug, 'about'),
key: 'about',
content: aboutTab,
},
{
title: 'Posts',
content: postsPage,
},
]
const pageContent = sidebarPages[sidebarIndex].content
const onSidebarClick = (key: string) => {
const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index)
// Append the page to the URL, e.g. /group/mexifold/markets
router.replace(
{ query: { ...router.query, slugs: [group.slug, key] } },
undefined,
{ shallow: true }
)
}
const joinOrAddQuestionsButton = (
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
)
return (
<>
<TopGroupNavBar
group={group}
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
<Page logoSubheading={group.name}>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<div>
<div
className={
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
}
>
<Toaster />
<GroupSidebar
groupName={group.name}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
onClick={onSidebarClick}
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
currentKey={sidebarPages[sidebarIndex].key}
/>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
{pageContent}
</main>
</div>
<TopGroupNavBar group={group} />
<div className={'relative p-2 pt-0 md:pt-2'}>
<Tabs className={'mb-2'} tabs={tabs} />
</div>
</>
</Page>
)
}
export function TopGroupNavBar(props: {
group: Group
currentPage: string
onClick: (key: string) => void
}) {
export function TopGroupNavBar(props: { group: Group }) {
return (
<header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12">
<div className="flex items-center bg-white px-4">
@ -317,7 +300,6 @@ export function TopGroupNavBar(props: {
</h1>
</div>
</div>
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
</header>
)
}
@ -330,11 +312,13 @@ function JoinOrAddQuestionsButtons(props: {
}) {
const { group, user, isMember } = props
return user && isMember ? (
<Row className={'w-full self-start pt-4'}>
<Row className={'mb-2 w-full self-start md:mt-2 '}>
<AddContractButton group={group} user={user} />
</Row>
) : group.anyoneCanJoin ? (
<JoinGroupButton group={group} user={user} />
<div className="mb-2 md:mb-0">
<JoinGroupButton group={group} user={user} />
</div>
) : null
}
@ -451,7 +435,7 @@ function GroupLeaderboard(props: {
return (
<Leaderboard
className="max-w-xl"
users={topUsers.map((t) => t.user)}
entries={topUsers.map((t) => t.user)}
title={title}
columns={[
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },
@ -461,6 +445,84 @@ function GroupLeaderboard(props: {
)
}
function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser()
const createPost = <CreatePost group={group} />
const postList = (
<div className=" align-start w-full items-start">
<Row className="flex justify-between">
<Col>
<Title text={'Posts'} className="!mt-0" />
</Col>
<Col>
{user && (
<Button
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post
</Button>
)}
</Col>
</Row>
<div className="mt-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</div>
)
return showCreatePost ? createPost : postList
}
function PostCard(props: { post: Post }) {
const { post } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
if (!user) return <> </>
return (
<div className="py-1">
<Link href={postPath(post.slug)}>
<Row
className={
'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100'
}
>
<div className="flex-shrink-0">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className="">
<div className="text-sm text-gray-500">
<UserLink
className="text-neutral"
name={user?.name}
username={user?.username}
/>
<span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div>
<div className="text-lg font-medium text-gray-900">
{post.title}
</div>
</div>
</Row>
</Link>
</div>
)
}
function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props
const [open, setOpen] = useState(false)

View File

@ -99,6 +99,7 @@ export default function Groups(props: {
</div>
<Tabs
className="mb-4"
currentPageForAnalytics={'groups'}
tabs={[
...(user

View File

@ -28,7 +28,7 @@ export default function Home() {
}
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections)
const { sections } = getHomeItems(groups ?? [], homeSections)
return (
<Page>

View File

@ -8,6 +8,7 @@ import {
import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { toast, Toaster } from 'react-hot-toast'
import { Dictionary } from 'lodash'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
@ -31,11 +32,10 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { formatMoney } from 'common/util/format'
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { ProfitBadge } from 'web/components/bets-list'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
import { useContractsQuery } from 'web/hooks/use-contracts'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { PillButton } from 'web/components/buttons/pill-button'
import { filterDefined } from 'common/util/array'
@ -43,6 +43,8 @@ import { updateUser } from 'web/lib/firebase/users'
import { isArray, keyBy } from 'lodash'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { Title } from 'web/components/title'
import { CPMMBinaryContract } from 'common/contract'
import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts'
export default function Home() {
const user = useUser()
@ -54,20 +56,19 @@ export default function Home() {
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? [])
useEffect(() => {
if (
user &&
!user.homeSections &&
sections.length > 0 &&
groups.length > 0
) {
if (user && !user.homeSections && sections.length > 0 && groups) {
// Save initial home sections.
updateUser(user.id, { homeSections: sections.map((s) => s.id) })
}
}, [user, sections, groups])
const groupContracts = useContractsByDailyScoreGroups(
groups?.map((g) => g.slug)
)
return (
<Page>
<Toaster />
@ -81,9 +82,13 @@ export default function Home() {
<DailyStats user={user} />
</Row>
{sections.map((section) => renderSection(section, user, groups))}
<>
{sections.map((section) =>
renderSection(section, user, groups, groupContracts)
)}
<TrendingGroupsSection user={user} />
<TrendingGroupsSection user={user} />
</>
</Col>
<button
type="button"
@ -101,9 +106,9 @@ export default function Home() {
const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Daily trending', id: 'daily-trending' },
{ label: 'Trending', id: 'score' },
{ label: 'New', id: 'newest' },
{ label: 'Recently updated', id: 'recently-updated-for-you' },
]
export const getHomeItems = (groups: Group[], sections: string[]) => {
@ -134,18 +139,19 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
function renderSection(
section: { id: string; label: string },
user: User | null | undefined,
groups: Group[]
groups: Group[] | undefined,
groupContracts: Dictionary<CPMMBinaryContract[]> | undefined
) {
const { id, label } = section
if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} />
}
if (id === 'recently-updated-for-you')
if (id === 'daily-trending')
return (
<SearchSection
key={id}
label={label}
sort={'last-updated'}
sort={'daily-score'}
pill="personal"
user={user}
/>
@ -156,8 +162,23 @@ function renderSection(
<SearchSection key={id} label={label} sort={sort.value} user={user} />
)
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
if (groups && groupContracts) {
const group = groups.find((g) => g.id === id)
if (group) {
const contracts = groupContracts[group.slug].filter(
(c) => Math.abs(c.probChanges.day) >= 0.01
)
if (contracts.length === 0) return null
return (
<GroupSection
key={id}
group={group}
user={user}
contracts={contracts}
/>
)
}
}
return null
}
@ -207,7 +228,6 @@ function SearchSection(props: {
defaultPill={pill}
noControls
maxResults={6}
headerClassName="sticky"
persistPrefix={`home-${sort}`}
/>
</Col>
@ -217,10 +237,9 @@ function SearchSection(props: {
function GroupSection(props: {
group: Group
user: User | null | undefined | undefined
contracts: CPMMBinaryContract[]
}) {
const { group, user } = props
const contracts = useContractsQuery('score', 4, { groupSlug: group.slug })
const { group, user, contracts } = props
return (
<Col>
@ -245,22 +264,22 @@ function GroupSection(props: {
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
</Button>
</SectionHeader>
<ContractsGrid contracts={contracts} />
<ContractsGrid
contracts={contracts.slice(0, 4)}
cardUIOptions={{ showProbChange: true }}
/>
</Col>
)
}
function DailyMoversSection(props: { userId: string | null | undefined }) {
const { userId } = props
const changes = useProbChangesAlgolia(userId ?? '')
const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter(
(c) => Math.abs(c.probChanges.day) >= 0.01
)
if (changes) {
const { positiveChanges, negativeChanges } = changes
if (
!positiveChanges.find((c) => c.probChanges.day >= 0.01) ||
!negativeChanges.find((c) => c.probChanges.day <= -0.01)
)
return null
if (changes && changes.length === 0) {
return null
}
return (
@ -332,6 +351,10 @@ export function TrendingGroupsSection(props: {
const count = full ? 100 : 25
const chosenGroups = groups.slice(0, count)
if (chosenGroups.length === 0) {
return null
}
return (
<Col className={className}>
<SectionHeader label="Trending groups" href="/explore-groups">

View File

@ -81,7 +81,7 @@ export default function Leaderboards(_props: {
<Col className="mx-4 items-center gap-10 lg:flex-row">
<Leaderboard
title={`🏅 Top ${BETTORS}`}
users={topTraders}
entries={topTraders}
columns={[
{
header: 'Total profit',
@ -92,7 +92,7 @@ export default function Leaderboards(_props: {
<Leaderboard
title="🏅 Top creators"
users={topCreators}
entries={topCreators}
columns={[
{
header: 'Total bet',
@ -106,7 +106,7 @@ export default function Leaderboards(_props: {
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
<Leaderboard
title="🏅 Top followed"
users={topFollowed}
entries={topFollowed}
columns={[
{
header: 'Total followers',
@ -132,6 +132,7 @@ export default function Leaderboards(_props: {
/>
<Title text={'Leaderboards'} className={'hidden md:block'} />
<Tabs
className="mb-4"
currentPageForAnalytics={'leaderboards'}
defaultIndex={1}
tabs={[

View File

@ -26,6 +26,7 @@ export default function Analytics() {
return (
<Page>
<Tabs
className="mb-4"
currentPageForAnalytics={'stats'}
tabs={[
{
@ -89,6 +90,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} />
<Tabs
className="mb-4"
defaultIndex={1}
tabs={[
{
@ -141,6 +143,7 @@ export function CustomAnalytics(props: Stats) {
period?
</p>
<Tabs
className="mb-4"
defaultIndex={1}
tabs={[
{
@ -198,6 +201,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} />
<Tabs
className="mb-4"
defaultIndex={2}
tabs={[
{
@ -239,6 +243,7 @@ export function CustomAnalytics(props: Stats) {
<Title text="Daily activity" />
<Tabs
className="mb-4"
defaultIndex={0}
tabs={[
{
@ -293,6 +298,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} />
<Tabs
className="mb-4"
defaultIndex={1}
tabs={[
{
@ -323,6 +329,7 @@ export function CustomAnalytics(props: Stats) {
<Title text="Ratio of Active Users" />
<Tabs
className="mb-4"
defaultIndex={1}
tabs={[
{
@ -367,6 +374,7 @@ export function CustomAnalytics(props: Stats) {
Sum of bet amounts. (Divided by 100 to be more readable.)
</p>
<Tabs
className="mb-4"
defaultIndex={1}
tabs={[
{

View File

@ -83,14 +83,14 @@ const tourneys: Tourney[] = [
endTime: toDate('Sep 30, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM',
},
{
title: 'Manifold F2P Tournament',
blurb:
'Who can amass the most mana starting from a free-to-play (F2P) account?',
award: 'Poem',
endTime: toDate('Sep 15, 2022'),
groupId: '6rrIja7tVW00lUVwtsYS',
},
// {
// title: 'Manifold F2P Tournament',
// blurb:
// 'Who can amass the most mana starting from a free-to-play (F2P) account?',
// award: 'Poem',
// endTime: toDate('Sep 15, 2022'),
// groupId: '6rrIja7tVW00lUVwtsYS',
// },
// {
// title: 'Cause Exploration Prizes',
// blurb:

View File

@ -1,8 +1,7 @@
import { LinkIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { PrivateUser, User } from 'common/user'
import Link from 'next/link'
import { MouseEventHandler, ReactNode, useState } from 'react'
import { MouseEventHandler, ReactNode, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Button } from 'web/components/button'
@ -17,11 +16,7 @@ import { Title } from 'web/components/title'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { useTracking } from 'web/hooks/use-tracking'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
firebaseLogin,
getUserAndPrivateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { firebaseLogin, updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import {
getDockURLForUser,
@ -40,23 +35,32 @@ function ButtonGetStarted(props: {
const { user, privateUser, buttonClass, spinnerClass } = props
const [isLoading, setLoading] = useState(false)
const needsRelink =
privateUser?.twitchInfo?.twitchName &&
privateUser?.twitchInfo?.needsRelinking
const [waitingForUser, setWaitingForUser] = useState(false)
useEffect(() => {
if (waitingForUser && user && privateUser) {
setWaitingForUser(false)
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
setLoading(true)
linkTwitchAccountRedirect(user, privateUser).then(() => {
setLoading(false)
})
}
}, [user, privateUser, waitingForUser])
const callback =
user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser)
: async () => {
const result = await firebaseLogin()
const userId = result.user.uid
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
await linkTwitchAccountRedirect(user, privateUser)
await firebaseLogin()
setWaitingForUser(true)
}
const getStarted = async () => {
@ -110,20 +114,9 @@ function TwitchPlaysManifoldMarkets(props: {
className={'!-my-0 md:block'}
/>
</Row>
<Col className="gap-4">
<div>
Similar to Twitch channel point predictions, Manifold Markets allows
you to create and feature on stream any question you like with users
predicting to earn play money.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
Start playing now by logging in with Google and typing commands in chat!
<Col className="mb-4 gap-4">
Start betting on Twitch now by linking your account and typing commands
in chat!
{twitchUser && !twitchInfo.needsRelinking ? (
<Button
size="xl"
@ -135,13 +128,25 @@ function TwitchPlaysManifoldMarkets(props: {
) : (
<ButtonGetStarted user={user} privateUser={privateUser} />
)}
</Col>
<Col className="gap-4">
<Subtitle text="How it works" />
<div>
Instead of Twitch channel points we use our play money, Mana (M$). All
viewers start with M$1000 and more can be earned for free and then{' '}
<Link href="/charity">
<a className="underline">donated to a charity</a>
</Link>{' '}
of their choice at no cost!
Similar to Twitch channel point predictions, Manifold Markets allows
you to create a play-money betting market on any question you like and
feature it in your stream.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
<div>
Instead of Twitch channel points we use our own play money, mana (M$).
All viewers start with M$1,000 and can earn more for free by betting
well.
</div>
</Col>
</div>
@ -170,21 +175,26 @@ function TwitchChatCommands() {
<Title text="Twitch Chat Commands" className="md:block" />
<Col className="gap-4">
<Subtitle text="For Chat" />
<Command command="bet yes#" desc="Bets a # of Mana on yes." />
<Command command="bet no#" desc="Bets a # of Mana on no." />
<Command
command="bet yes #"
desc="Bets an amount of M$ on yes, for example !bet yes 20"
/>
<Command command="bet no #" desc="Bets an amount of M$ on no." />
<Command
command="sell"
desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable
(if the probability has moved towards the direction you bet) or cause
a loss, although at least you keep some Mana. For maximum profit (but
a loss, although at least you keep some mana. For maximum profit (but
also risk) it is better to not sell and wait for a favourable
resolution."
/>
<Command command="balance" desc="Shows how much Mana you own." />
<Command command="balance" desc="Shows how much M$ you have." />
<Command command="allin yes" desc="Bets your entire balance on yes." />
<Command command="allin no" desc="Bets your entire balance on no." />
<div className="mb-4" />
<Subtitle text="For Mods/Streamer" />
<Command
command="create <question>"
@ -194,7 +204,7 @@ function TwitchChatCommands() {
<Command command="resolve no" desc="Resolves the market as 'No'." />
<Command
command="resolve n/a"
desc="Resolves the market as 'N/A' and refunds everyone their Mana."
desc="Resolves the market as 'N/A' and refunds everyone their mana."
/>
</Col>
</div>

View File

@ -92,7 +92,7 @@ export function PostCommentInput(props: {
return (
<CommentInput
replyToUser={replyToUser}
replyTo={replyToUser}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
/>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

View File

@ -15,9 +15,6 @@ module.exports = {
}
),
extend: {
backgroundImage: {
'world-trading': "url('/world-trading-background.webp')",
},
colors: {
'greyscale-1': '#FBFBFF',
'greyscale-2': '#E7E7F4',

175
yarn.lock
View File

@ -2476,10 +2476,10 @@
resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b"
integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==
"@next/env@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.5.tgz#d908c57b35262b94db3e431e869b72ac3e1ad3e3"
integrity sha512-vLPLV3cpPGjUPT3PjgRj7e3nio9t6USkuew3JE/jMeon/9Mvp1WyR18v3iwnCuX7eUAm1HmAbJHHLAbcu/EJcw==
"@next/env@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.3.1.tgz#18266bd92de3b4aa4037b1927aa59e6f11879260"
integrity sha512-9P9THmRFVKGKt9DYqeC2aKIxm8rlvkK38V1P1sRE7qyoPBIs8l9oo79QoSdPtOWfzkbDAVUqvbQGgTMsb8BtJg==
"@next/eslint-plugin-next@12.1.6":
version "12.1.6"
@ -2488,70 +2488,70 @@
dependencies:
glob "7.1.7"
"@next/swc-android-arm-eabi@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.5.tgz#903a5479ab4c2705d9c08d080907475f7bacf94d"
integrity sha512-cPWClKxGhgn2dLWnspW+7psl3MoLQUcNqJqOHk2BhNcou9ARDtC0IjQkKe5qcn9qg7I7U83Gp1yh2aesZfZJMA==
"@next/swc-android-arm-eabi@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.1.tgz#b15ce8ad376102a3b8c0f3c017dde050a22bb1a3"
integrity sha512-i+BvKA8tB//srVPPQxIQN5lvfROcfv4OB23/L1nXznP+N/TyKL8lql3l7oo2LNhnH66zWhfoemg3Q4VJZSruzQ==
"@next/swc-android-arm64@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.5.tgz#2f9a98ec4166c7860510963b31bda1f57a77c792"
integrity sha512-vMj0efliXmC5b7p+wfcQCX0AfU8IypjkzT64GiKJD9PgiA3IILNiGJr1fw2lyUDHkjeWx/5HMlMEpLnTsQslwg==
"@next/swc-android-arm64@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.3.1.tgz#85d205f568a790a137cb3c3f720d961a2436ac9c"
integrity sha512-CmgU2ZNyBP0rkugOOqLnjl3+eRpXBzB/I2sjwcGZ7/Z6RcUJXK5Evz+N0ucOxqE4cZ3gkTeXtSzRrMK2mGYV8Q==
"@next/swc-darwin-arm64@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.5.tgz#31b1c3c659d54be546120c488a1e1bad21c24a1d"
integrity sha512-VOPWbO5EFr6snla/WcxUKtvzGVShfs302TEMOtzYyWni6f9zuOetijJvVh9CCTzInnXAZMtHyNhefijA4HMYLg==
"@next/swc-darwin-arm64@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.1.tgz#b105457d6760a7916b27e46c97cb1a40547114ae"
integrity sha512-hT/EBGNcu0ITiuWDYU9ur57Oa4LybD5DOQp4f22T6zLfpoBMfBibPtR8XktXmOyFHrL/6FC2p9ojdLZhWhvBHg==
"@next/swc-darwin-x64@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.5.tgz#2e44dd82b2b7fef88238d1bc4d3bead5884cedfd"
integrity sha512-5o8bTCgAmtYOgauO/Xd27vW52G2/m3i5PX7MUYePquxXAnX73AAtqA3WgPXBRitEB60plSKZgOTkcpqrsh546A==
"@next/swc-darwin-x64@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.1.tgz#6947b39082271378896b095b6696a7791c6e32b1"
integrity sha512-9S6EVueCVCyGf2vuiLiGEHZCJcPAxglyckTZcEwLdJwozLqN0gtS0Eq0bQlGS3dH49Py/rQYpZ3KVWZ9BUf/WA==
"@next/swc-freebsd-x64@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.5.tgz#e24e75d8c2581bfebc75e4f08f6ddbd116ce9dbd"
integrity sha512-yYUbyup1JnznMtEBRkK4LT56N0lfK5qNTzr6/DEyDw5TbFVwnuy2hhLBzwCBkScFVjpFdfiC6SQAX3FrAZzuuw==
"@next/swc-freebsd-x64@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.1.tgz#2b6c36a4d84aae8b0ea0e0da9bafc696ae27085a"
integrity sha512-qcuUQkaBZWqzM0F1N4AkAh88lLzzpfE6ImOcI1P6YeyJSsBmpBIV8o70zV+Wxpc26yV9vpzb+e5gCyxNjKJg5Q==
"@next/swc-linux-arm-gnueabihf@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.5.tgz#46d8c514d834d2b5f67086013f0bd5e3081e10b9"
integrity sha512-2ZE2/G921Acks7UopJZVMgKLdm4vN4U0yuzvAMJ6KBavPzqESA2yHJlm85TV/K9gIjKhSk5BVtauIUntFRP8cg==
"@next/swc-linux-arm-gnueabihf@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.1.tgz#6e421c44285cfedac1f4631d5de330dd60b86298"
integrity sha512-diL9MSYrEI5nY2wc/h/DBewEDUzr/DqBjIgHJ3RUNtETAOB3spMNHvJk2XKUDjnQuluLmFMloet9tpEqU2TT9w==
"@next/swc-linux-arm64-gnu@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.5.tgz#91f725ac217d3a1f4f9f53b553615ba582fd3d9f"
integrity sha512-/I6+PWVlz2wkTdWqhlSYYJ1pWWgUVva6SgX353oqTh8njNQp1SdFQuWDqk8LnM6ulheVfSsgkDzxrDaAQZnzjQ==
"@next/swc-linux-arm64-gnu@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.1.tgz#8863f08a81f422f910af126159d2cbb9552ef717"
integrity sha512-o/xB2nztoaC7jnXU3Q36vGgOolJpsGG8ETNjxM1VAPxRwM7FyGCPHOMk1XavG88QZSQf+1r+POBW0tLxQOJ9DQ==
"@next/swc-linux-arm64-musl@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.5.tgz#e627e8c867920995810250303cd9b8e963598383"
integrity sha512-LPQRelfX6asXyVr59p5sTpx5l+0yh2Vjp/R8Wi4X9pnqcayqT4CUJLiHqCvZuLin3IsFdisJL0rKHMoaZLRfmg==
"@next/swc-linux-arm64-musl@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.1.tgz#0038f07cf0b259d70ae0c80890d826dfc775d9f3"
integrity sha512-2WEasRxJzgAmP43glFNhADpe8zB7kJofhEAVNbDJZANp+H4+wq+/cW1CdDi8DqjkShPEA6/ejJw+xnEyDID2jg==
"@next/swc-linux-x64-gnu@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.5.tgz#83a5e224fbc4d119ef2e0f29d0d79c40cc43887e"
integrity sha512-0szyAo8jMCClkjNK0hknjhmAngUppoRekW6OAezbEYwHXN/VNtsXbfzgYOqjKWxEx3OoAzrT3jLwAF0HdX2MEw==
"@next/swc-linux-x64-gnu@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.1.tgz#c66468f5e8181ffb096c537f0dbfb589baa6a9c1"
integrity sha512-JWEaMyvNrXuM3dyy9Pp5cFPuSSvG82+yABqsWugjWlvfmnlnx9HOQZY23bFq3cNghy5V/t0iPb6cffzRWylgsA==
"@next/swc-linux-x64-musl@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.5.tgz#be700d48471baac1ec2e9539396625584a317e95"
integrity sha512-zg/Y6oBar1yVnW6Il1I/08/2ukWtOG6s3acdJdEyIdsCzyQi4RLxbbhkD/EGQyhqBvd3QrC6ZXQEXighQUAZ0g==
"@next/swc-linux-x64-musl@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.1.tgz#c6269f3e96ac0395bc722ad97ce410ea5101d305"
integrity sha512-xoEWQQ71waWc4BZcOjmatuvPUXKTv6MbIFzpm4LFeCHsg2iwai0ILmNXf81rJR+L1Wb9ifEke2sQpZSPNz1Iyg==
"@next/swc-win32-arm64-msvc@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.5.tgz#a93e958133ad3310373fda33a79aa10af2a0aa97"
integrity sha512-3/90DRNSqeeSRMMEhj4gHHQlLhhKg5SCCoYfE3kBjGpE63EfnblYUqsszGGZ9ekpKL/R4/SGB40iCQr8tR5Jiw==
"@next/swc-win32-arm64-msvc@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.1.tgz#83c639ee969cee36ce247c3abd1d9df97b5ecade"
integrity sha512-hswVFYQYIeGHE2JYaBVtvqmBQ1CppplQbZJS/JgrVI3x2CurNhEkmds/yqvDONfwfbttTtH4+q9Dzf/WVl3Opw==
"@next/swc-win32-ia32-msvc@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.5.tgz#4f5f7ba0a98ff89a883625d4af0125baed8b2e19"
integrity sha512-hGLc0ZRAwnaPL4ulwpp4D2RxmkHQLuI8CFOEEHdzZpS63/hMVzv81g8jzYA0UXbb9pus/iTc3VRbVbAM03SRrw==
"@next/swc-win32-ia32-msvc@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.1.tgz#52995748b92aa8ad053440301bc2c0d9fbcf27c2"
integrity sha512-Kny5JBehkTbKPmqulr5i+iKntO5YMP+bVM8Hf8UAmjSMVo3wehyLVc9IZkNmcbxi+vwETnQvJaT5ynYBkJ9dWA==
"@next/swc-win32-x64-msvc@12.2.5":
version "12.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.5.tgz#20fed129b04a0d3f632c6d0de135345bb623b1e4"
integrity sha512-7h5/ahY7NeaO2xygqVrSG/Y8Vs4cdjxIjowTZ5W6CKoTKn7tmnuxlUc2h74x06FKmbhAd9agOjr/AOKyxYYm9Q==
"@next/swc-win32-x64-msvc@12.3.1":
version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136"
integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA==
"@nivo/annotations@0.74.0":
version "0.74.0"
@ -2933,10 +2933,10 @@
"@svgr/plugin-jsx" "^6.2.1"
"@svgr/plugin-svgo" "^6.2.0"
"@swc/helpers@0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.3.tgz#16593dfc248c53b699d4b5026040f88ddb497012"
integrity sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA==
"@swc/helpers@0.4.11":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de"
integrity sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==
dependencies:
tslib "^2.4.0"
@ -4545,6 +4545,11 @@ caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498"
integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==
caniuse-lite@^1.0.30001406:
version "1.0.30001409"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001409.tgz#6135da9dcab34cd9761d9cdb12a68e6740c5e96e"
integrity sha512-V0mnJ5dwarmhYv8/MzhJ//aW68UpvnQBXv8lJ2QUsvn2pHcmAuNtu8hQEDz37XnA1iE+lRR9CIfGWWpgJ5QedQ==
ccount@^1.0.0, ccount@^1.0.3:
version "1.1.0"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043"
@ -8637,31 +8642,31 @@ next-sitemap@^2.5.14:
"@corex/deepmerge" "^2.6.148"
minimist "^1.2.6"
next@12.2.5:
version "12.2.5"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717"
integrity sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA==
next@12.3.1:
version "12.3.1"
resolved "https://registry.yarnpkg.com/next/-/next-12.3.1.tgz#127b825ad2207faf869b33393ec8c75fe61e50f1"
integrity sha512-l7bvmSeIwX5lp07WtIiP9u2ytZMv7jIeB8iacR28PuUEFG5j0HGAPnMqyG5kbZNBG2H7tRsrQ4HCjuMOPnANZw==
dependencies:
"@next/env" "12.2.5"
"@swc/helpers" "0.4.3"
caniuse-lite "^1.0.30001332"
"@next/env" "12.3.1"
"@swc/helpers" "0.4.11"
caniuse-lite "^1.0.30001406"
postcss "8.4.14"
styled-jsx "5.0.4"
styled-jsx "5.0.7"
use-sync-external-store "1.2.0"
optionalDependencies:
"@next/swc-android-arm-eabi" "12.2.5"
"@next/swc-android-arm64" "12.2.5"
"@next/swc-darwin-arm64" "12.2.5"
"@next/swc-darwin-x64" "12.2.5"
"@next/swc-freebsd-x64" "12.2.5"
"@next/swc-linux-arm-gnueabihf" "12.2.5"
"@next/swc-linux-arm64-gnu" "12.2.5"
"@next/swc-linux-arm64-musl" "12.2.5"
"@next/swc-linux-x64-gnu" "12.2.5"
"@next/swc-linux-x64-musl" "12.2.5"
"@next/swc-win32-arm64-msvc" "12.2.5"
"@next/swc-win32-ia32-msvc" "12.2.5"
"@next/swc-win32-x64-msvc" "12.2.5"
"@next/swc-android-arm-eabi" "12.3.1"
"@next/swc-android-arm64" "12.3.1"
"@next/swc-darwin-arm64" "12.3.1"
"@next/swc-darwin-x64" "12.3.1"
"@next/swc-freebsd-x64" "12.3.1"
"@next/swc-linux-arm-gnueabihf" "12.3.1"
"@next/swc-linux-arm64-gnu" "12.3.1"
"@next/swc-linux-arm64-musl" "12.3.1"
"@next/swc-linux-x64-gnu" "12.3.1"
"@next/swc-linux-x64-musl" "12.3.1"
"@next/swc-win32-arm64-msvc" "12.3.1"
"@next/swc-win32-ia32-msvc" "12.3.1"
"@next/swc-win32-x64-msvc" "12.3.1"
no-case@^3.0.4:
version "3.0.4"
@ -11267,10 +11272,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0:
dependencies:
inline-style-parser "0.1.1"
styled-jsx@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.4.tgz#5b1bd0b9ab44caae3dd1361295559706e044aa53"
integrity sha512-sDFWLbg4zR+UkNzfk5lPilyIgtpddfxXEULxhujorr5jtePTUqiPDc5BC0v1NRqTr/WaFBGQQUoYToGlF4B2KQ==
styled-jsx@5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.7.tgz#be44afc53771b983769ac654d355ca8d019dff48"
integrity sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==
stylehacks@^5.1.0:
version "5.1.0"