Merge branch 'main' into theoremone
This commit is contained in:
		
						commit
						5d64d53c65
					
				|  | @ -4,6 +4,7 @@ export type Bet = { | |||
|   contractId: string | ||||
| 
 | ||||
|   amount: number // bet size; negative if SELL bet
 | ||||
|   loanAmount?: number | ||||
|   outcome: string | ||||
|   shares: number // dynamic parimutuel pool weight; negative if SELL bet
 | ||||
| 
 | ||||
|  | @ -21,3 +22,5 @@ export type Bet = { | |||
| 
 | ||||
|   createdTime: number | ||||
| } | ||||
| 
 | ||||
| export const MAX_LOAN_PER_CONTRACT = 20 | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import { Bet } from './bet' | ||||
| import * as _ from 'lodash' | ||||
| import { Bet, MAX_LOAN_PER_CONTRACT } from './bet' | ||||
| import { | ||||
|   calculateShares, | ||||
|   getProbability, | ||||
|  | @ -11,6 +12,7 @@ export const getNewBinaryBetInfo = ( | |||
|   user: User, | ||||
|   outcome: 'YES' | 'NO', | ||||
|   amount: number, | ||||
|   loanAmount: number, | ||||
|   contract: Contract, | ||||
|   newBetId: string | ||||
| ) => { | ||||
|  | @ -45,6 +47,7 @@ export const getNewBinaryBetInfo = ( | |||
|     userId: user.id, | ||||
|     contractId: contract.id, | ||||
|     amount, | ||||
|     loanAmount, | ||||
|     shares, | ||||
|     outcome, | ||||
|     probBefore, | ||||
|  | @ -52,7 +55,7 @@ export const getNewBinaryBetInfo = ( | |||
|     createdTime: Date.now(), | ||||
|   } | ||||
| 
 | ||||
|   const newBalance = user.balance - amount | ||||
|   const newBalance = user.balance - (amount - loanAmount) | ||||
| 
 | ||||
|   return { newBet, newPool, newTotalShares, newTotalBets, newBalance } | ||||
| } | ||||
|  | @ -61,6 +64,7 @@ export const getNewMultiBetInfo = ( | |||
|   user: User, | ||||
|   outcome: string, | ||||
|   amount: number, | ||||
|   loanAmount: number, | ||||
|   contract: Contract, | ||||
|   newBetId: string | ||||
| ) => { | ||||
|  | @ -85,6 +89,7 @@ export const getNewMultiBetInfo = ( | |||
|     userId: user.id, | ||||
|     contractId: contract.id, | ||||
|     amount, | ||||
|     loanAmount, | ||||
|     shares, | ||||
|     outcome, | ||||
|     probBefore, | ||||
|  | @ -92,7 +97,17 @@ export const getNewMultiBetInfo = ( | |||
|     createdTime: Date.now(), | ||||
|   } | ||||
| 
 | ||||
|   const newBalance = user.balance - amount | ||||
|   const newBalance = user.balance - (amount - loanAmount) | ||||
| 
 | ||||
|   return { newBet, newPool, newTotalShares, newTotalBets, newBalance } | ||||
| } | ||||
| 
 | ||||
| export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { | ||||
|   const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale) | ||||
|   const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0) | ||||
|   const loanAmount = Math.min( | ||||
|     newBetAmount, | ||||
|     MAX_LOAN_PER_CONTRACT - prevLoanAmount | ||||
|   ) | ||||
|   return loanAmount | ||||
| } | ||||
|  |  | |||
|  | @ -138,8 +138,7 @@ export const getPayoutsMultiOutcome = ( | |||
|     const prob = resolutions[outcome] / probTotal | ||||
|     const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal | ||||
|     const profit = winnings - amount | ||||
| 
 | ||||
|     const payout = amount + (1 - FEES) * Math.max(0, profit) | ||||
|     const payout = deductFees(amount, winnings) | ||||
|     return { userId, profit, payout } | ||||
|   }) | ||||
| 
 | ||||
|  | @ -161,3 +160,12 @@ export const getPayoutsMultiOutcome = ( | |||
|     .map(({ userId, payout }) => ({ userId, payout })) | ||||
|     .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee
 | ||||
| } | ||||
| 
 | ||||
| export const getLoanPayouts = (bets: Bet[]) => { | ||||
|   const betsWithLoans = bets.filter((bet) => bet.loanAmount) | ||||
|   const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId) | ||||
|   const loansByUser = _.mapValues(betsByUser, (bets) => | ||||
|     _.sumBy(bets, (bet) => -(bet.loanAmount ?? 0)) | ||||
|   ) | ||||
|   return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout })) | ||||
| } | ||||
|  |  | |||
|  | @ -45,8 +45,9 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { | |||
|   const investments = bets | ||||
|     .filter((bet) => !bet.sale) | ||||
|     .map((bet) => { | ||||
|       const { userId, amount } = bet | ||||
|       return { userId, payout: -amount } | ||||
|       const { userId, amount, loanAmount } = bet | ||||
|       const payout = -amount - (loanAmount ?? 0) | ||||
|       return { userId, payout } | ||||
|     }) | ||||
| 
 | ||||
|   const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ export const getSellBetInfo = ( | |||
|   newBetId: string | ||||
| ) => { | ||||
|   const { pool, totalShares, totalBets } = contract | ||||
|   const { id: betId, amount, shares, outcome } = bet | ||||
|   const { id: betId, amount, shares, outcome, loanAmount } = bet | ||||
| 
 | ||||
|   const adjShareValue = calculateShareValue(contract, bet) | ||||
| 
 | ||||
|  | @ -57,7 +57,7 @@ export const getSellBetInfo = ( | |||
|     }, | ||||
|   } | ||||
| 
 | ||||
|   const newBalance = user.balance + saleAmount | ||||
|   const newBalance = user.balance + saleAmount - (loanAmount ?? 0) | ||||
| 
 | ||||
|   return { | ||||
|     newBet, | ||||
|  |  | |||
|  | @ -3,10 +3,11 @@ import * as admin from 'firebase-admin' | |||
| 
 | ||||
| import { Contract } from '../../common/contract' | ||||
| import { User } from '../../common/user' | ||||
| import { getNewMultiBetInfo } from '../../common/new-bet' | ||||
| import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet' | ||||
| import { Answer } from '../../common/answer' | ||||
| import { getContract, getValues } from './utils' | ||||
| import { sendNewAnswerEmail } from './emails' | ||||
| import { Bet } from '../../common/bet' | ||||
| 
 | ||||
| export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( | ||||
|   async ( | ||||
|  | @ -55,6 +56,11 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( | |||
|       if (closeTime && Date.now() > closeTime) | ||||
|         return { status: 'error', message: 'Trading is closed' } | ||||
| 
 | ||||
|       const yourBetsSnap = await transaction.get( | ||||
|         contractDoc.collection('bets').where('userId', '==', userId) | ||||
|       ) | ||||
|       const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) | ||||
| 
 | ||||
|       const [lastAnswer] = await getValues<Answer>( | ||||
|         firestore | ||||
|           .collection(`contracts/${contractId}/answers`) | ||||
|  | @ -92,8 +98,17 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( | |||
|         .collection(`contracts/${contractId}/bets`) | ||||
|         .doc() | ||||
| 
 | ||||
|       const loanAmount = getLoanAmount(yourBets, amount) | ||||
| 
 | ||||
|       const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = | ||||
|         getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) | ||||
|         getNewMultiBetInfo( | ||||
|           user, | ||||
|           answerId, | ||||
|           amount, | ||||
|           loanAmount, | ||||
|           contract, | ||||
|           newBetDoc.id | ||||
|         ) | ||||
| 
 | ||||
|       transaction.create(newBetDoc, newBet) | ||||
|       transaction.update(contractDoc, { | ||||
|  |  | |||
|  | @ -223,6 +223,10 @@ export const sendNewAnswerEmail = async ( | |||
| ) => { | ||||
|   // Send to just the creator for now.
 | ||||
|   const { creatorId: userId } = contract | ||||
| 
 | ||||
|   // Don't send the creator's own answers.
 | ||||
|   if (answer.userId === userId) return | ||||
| 
 | ||||
|   const privateUser = await getPrivateUser(userId) | ||||
|   if ( | ||||
|     !privateUser || | ||||
|  |  | |||
|  | @ -3,7 +3,12 @@ import * as admin from 'firebase-admin' | |||
| 
 | ||||
| import { Contract } from '../../common/contract' | ||||
| import { User } from '../../common/user' | ||||
| import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet' | ||||
| import { | ||||
|   getLoanAmount, | ||||
|   getNewBinaryBetInfo, | ||||
|   getNewMultiBetInfo, | ||||
| } from '../../common/new-bet' | ||||
| import { Bet } from '../../common/bet' | ||||
| 
 | ||||
| export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( | ||||
|   async ( | ||||
|  | @ -33,9 +38,6 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( | |||
|         return { status: 'error', message: 'User not found' } | ||||
|       const user = userSnap.data() as User | ||||
| 
 | ||||
|       if (user.balance < amount) | ||||
|         return { status: 'error', message: 'Insufficient balance' } | ||||
| 
 | ||||
|       const contractDoc = firestore.doc(`contracts/${contractId}`) | ||||
|       const contractSnap = await transaction.get(contractDoc) | ||||
|       if (!contractSnap.exists) | ||||
|  | @ -46,6 +48,15 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( | |||
|       if (closeTime && Date.now() > closeTime) | ||||
|         return { status: 'error', message: 'Trading is closed' } | ||||
| 
 | ||||
|       const yourBetsSnap = await transaction.get( | ||||
|         contractDoc.collection('bets').where('userId', '==', userId) | ||||
|       ) | ||||
|       const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) | ||||
| 
 | ||||
|       const loanAmount = getLoanAmount(yourBets, amount) | ||||
|       if (user.balance < amount - loanAmount) | ||||
|         return { status: 'error', message: 'Insufficient balance' } | ||||
| 
 | ||||
|       if (outcomeType === 'FREE_RESPONSE') { | ||||
|         const answerSnap = await transaction.get( | ||||
|           contractDoc.collection('answers').doc(outcome) | ||||
|  | @ -64,10 +75,18 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( | |||
|               user, | ||||
|               outcome as 'YES' | 'NO', | ||||
|               amount, | ||||
|               loanAmount, | ||||
|               contract, | ||||
|               newBetDoc.id | ||||
|             ) | ||||
|           : getNewMultiBetInfo( | ||||
|               user, | ||||
|               outcome, | ||||
|               amount, | ||||
|               loanAmount, | ||||
|               contract, | ||||
|               newBetDoc.id | ||||
|             ) | ||||
|           : getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id) | ||||
| 
 | ||||
|       transaction.create(newBetDoc, newBet) | ||||
|       transaction.update(contractDoc, { | ||||
|  |  | |||
|  | @ -7,7 +7,11 @@ import { User } from '../../common/user' | |||
| import { Bet } from '../../common/bet' | ||||
| import { getUser, payUser } from './utils' | ||||
| import { sendMarketResolutionEmail } from './emails' | ||||
| import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts' | ||||
| import { | ||||
|   getLoanPayouts, | ||||
|   getPayouts, | ||||
|   getPayoutsMultiOutcome, | ||||
| } from '../../common/payouts' | ||||
| import { removeUndefinedProps } from '../../common/util/object' | ||||
| 
 | ||||
| export const resolveMarket = functions | ||||
|  | @ -99,13 +103,23 @@ export const resolveMarket = functions | |||
|           ? getPayoutsMultiOutcome(resolutions, contract, openBets) | ||||
|           : getPayouts(outcome, contract, openBets, resolutionProbability) | ||||
| 
 | ||||
|       const loanPayouts = getLoanPayouts(openBets) | ||||
| 
 | ||||
|       console.log('payouts:', payouts) | ||||
| 
 | ||||
|       const groups = _.groupBy(payouts, (payout) => payout.userId) | ||||
|       const groups = _.groupBy( | ||||
|         [...payouts, ...loanPayouts], | ||||
|         (payout) => payout.userId | ||||
|       ) | ||||
|       const userPayouts = _.mapValues(groups, (group) => | ||||
|         _.sumBy(group, (g) => g.payout) | ||||
|       ) | ||||
| 
 | ||||
|       const groupsWithoutLoans = _.groupBy(payouts, (payout) => payout.userId) | ||||
|       const userPayoutsWithoutLoans = _.mapValues(groupsWithoutLoans, (group) => | ||||
|         _.sumBy(group, (g) => g.payout) | ||||
|       ) | ||||
| 
 | ||||
|       const payoutPromises = Object.entries(userPayouts).map( | ||||
|         ([userId, payout]) => payUser(userId, payout) | ||||
|       ) | ||||
|  | @ -116,7 +130,7 @@ export const resolveMarket = functions | |||
| 
 | ||||
|       await sendResolutionEmails( | ||||
|         openBets, | ||||
|         userPayouts, | ||||
|         userPayoutsWithoutLoans, | ||||
|         creator, | ||||
|         contract, | ||||
|         outcome, | ||||
|  |  | |||
|  | @ -52,7 +52,8 @@ const computeInvestmentValue = async ( | |||
|     if (!contract || contract.isResolved) return 0 | ||||
|     if (bet.sale || bet.isSold) return 0 | ||||
| 
 | ||||
|     return calculatePayout(contract, bet, 'MKT') | ||||
|     const payout = calculatePayout(contract, bet, 'MKT') | ||||
|     return payout - (bet.loanAmount ?? 0) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -68,8 +68,7 @@ const updateUserBalance = ( | |||
| } | ||||
| 
 | ||||
| export const payUser = (userId: string, payout: number, isDeposit = false) => { | ||||
|   if (!isFinite(payout) || payout <= 0) | ||||
|     throw new Error('Payout is not positive: ' + payout) | ||||
|   if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) | ||||
| 
 | ||||
|   return updateUserBalance(userId, payout, isDeposit) | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,12 @@ | |||
| import _ from 'lodash' | ||||
| import { ContractFeed, ContractSummaryFeed } from '../components/contract-feed' | ||||
| import { Page } from '../components/page' | ||||
| import { | ||||
|   ContractActivityFeed, | ||||
|   ContractFeed, | ||||
|   ContractSummaryFeed, | ||||
| } from './contract-feed' | ||||
| import { Contract } from '../lib/firebase/contracts' | ||||
| import { Comment } from '../lib/firebase/comments' | ||||
| import { Col } from '../components/layout/col' | ||||
| import { Col } from './layout/col' | ||||
| import { Bet } from '../../common/bet' | ||||
| 
 | ||||
| const MAX_ACTIVE_CONTRACTS = 75 | ||||
|  | @ -72,30 +75,44 @@ export function findActiveContracts( | |||
| 
 | ||||
| export function ActivityFeed(props: { | ||||
|   contracts: Contract[] | ||||
|   contractBets: Bet[][] | ||||
|   contractComments: Comment[][] | ||||
|   recentBets: Bet[] | ||||
|   recentComments: Comment[] | ||||
|   loadBetAndCommentHistory?: boolean | ||||
| }) { | ||||
|   const { contracts, contractBets, contractComments } = props | ||||
|   const { contracts, recentBets, recentComments, loadBetAndCommentHistory } = | ||||
|     props | ||||
| 
 | ||||
|   return contracts.length > 0 ? ( | ||||
|   const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) | ||||
|   const groupedComments = _.groupBy( | ||||
|     recentComments, | ||||
|     (comment) => comment.contractId | ||||
|   ) | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="items-center"> | ||||
|       <Col className="w-full max-w-3xl"> | ||||
|       <Col className="w-full"> | ||||
|         <Col className="w-full divide-y divide-gray-300 self-center bg-white"> | ||||
|           {contracts.map((contract, i) => ( | ||||
|           {contracts.map((contract) => ( | ||||
|             <div key={contract.id} className="py-6 px-2 sm:px-4"> | ||||
|               <ContractFeed | ||||
|                 contract={contract} | ||||
|                 bets={contractBets[i]} | ||||
|                 comments={contractComments[i]} | ||||
|                 feedType="activity" | ||||
|               /> | ||||
|               {loadBetAndCommentHistory ? ( | ||||
|                 <ContractFeed | ||||
|                   contract={contract} | ||||
|                   bets={groupedBets[contract.id] ?? []} | ||||
|                   comments={groupedComments[contract.id] ?? []} | ||||
|                   feedType="activity" | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <ContractActivityFeed | ||||
|                   contract={contract} | ||||
|                   bets={groupedBets[contract.id] ?? []} | ||||
|                   comments={groupedComments[contract.id] ?? []} | ||||
|                 /> | ||||
|               )} | ||||
|             </div> | ||||
|           ))} | ||||
|         </Col> | ||||
|       </Col> | ||||
|     </Col> | ||||
|   ) : ( | ||||
|     <></> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
|  | @ -116,11 +133,3 @@ export function SummaryActivityFeed(props: { contracts: Contract[] }) { | |||
|     </Col> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function ActivityPage() { | ||||
|   return ( | ||||
|     <Page> | ||||
|       <ActivityFeed contracts={[]} contractBets={[]} contractComments={[]} /> | ||||
|     </Page> | ||||
|   ) | ||||
| } | ||||
|  | @ -1,15 +1,20 @@ | |||
| import clsx from 'clsx' | ||||
| import _ from 'lodash' | ||||
| import { useUser } from '../hooks/use-user' | ||||
| import { formatMoney } from '../../common/util/format' | ||||
| import { AddFundsButton } from './add-funds-button' | ||||
| import { Col } from './layout/col' | ||||
| import { Row } from './layout/row' | ||||
| import { useUserContractBets } from '../hooks/use-user-bets' | ||||
| import { MAX_LOAN_PER_CONTRACT } from '../../common/bet' | ||||
| import { InfoTooltip } from './info-tooltip' | ||||
| import { Spacer } from './layout/spacer' | ||||
| 
 | ||||
| export function AmountInput(props: { | ||||
|   amount: number | undefined | ||||
|   onChange: (newAmount: number | undefined) => void | ||||
|   error: string | undefined | ||||
|   setError: (error: string | undefined) => void | ||||
|   contractIdForLoan: string | undefined | ||||
|   minimumAmount?: number | ||||
|   disabled?: boolean | ||||
|   className?: string | ||||
|  | @ -22,6 +27,7 @@ export function AmountInput(props: { | |||
|     onChange, | ||||
|     error, | ||||
|     setError, | ||||
|     contractIdForLoan, | ||||
|     disabled, | ||||
|     className, | ||||
|     inputClassName, | ||||
|  | @ -31,14 +37,32 @@ export function AmountInput(props: { | |||
| 
 | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   const userBets = useUserContractBets(user?.id, contractIdForLoan) ?? [] | ||||
|   const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) | ||||
|   const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) | ||||
| 
 | ||||
|   const loanAmount = contractIdForLoan | ||||
|     ? Math.min(amount ?? 0, MAX_LOAN_PER_CONTRACT - prevLoanAmount) | ||||
|     : 0 | ||||
| 
 | ||||
|   const onAmountChange = (str: string) => { | ||||
|     if (str.includes('-')) { | ||||
|       onChange(undefined) | ||||
|       return | ||||
|     } | ||||
|     const amount = parseInt(str.replace(/[^\d]/, '')) | ||||
| 
 | ||||
|     if (str && isNaN(amount)) return | ||||
|     if (amount >= 10 ** 9) return | ||||
| 
 | ||||
|     onChange(str ? amount : undefined) | ||||
| 
 | ||||
|     if (user && user.balance < amount) { | ||||
|     const loanAmount = contractIdForLoan | ||||
|       ? Math.min(amount, MAX_LOAN_PER_CONTRACT - prevLoanAmount) | ||||
|       : 0 | ||||
|     const amountNetLoan = amount - loanAmount | ||||
| 
 | ||||
|     if (user && user.balance < amountNetLoan) { | ||||
|       setError('Insufficient balance') | ||||
|     } else if (minimumAmount && amount < minimumAmount) { | ||||
|       setError('Minimum amount: ' + formatMoney(minimumAmount)) | ||||
|  | @ -47,7 +71,8 @@ export function AmountInput(props: { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const remainingBalance = Math.max(0, (user?.balance ?? 0) - (amount ?? 0)) | ||||
|   const amountNetLoan = (amount ?? 0) - loanAmount | ||||
|   const remainingBalance = Math.max(0, (user?.balance ?? 0) - amountNetLoan) | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className={className}> | ||||
|  | @ -68,19 +93,34 @@ export function AmountInput(props: { | |||
|           onChange={(e) => onAmountChange(e.target.value)} | ||||
|         /> | ||||
|       </label> | ||||
| 
 | ||||
|       <Spacer h={4} /> | ||||
| 
 | ||||
|       {error && ( | ||||
|         <div className="mr-auto mt-4 self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> | ||||
|         <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> | ||||
|           {error} | ||||
|         </div> | ||||
|       )} | ||||
|       {user && ( | ||||
|         <Col className="mt-3 text-sm"> | ||||
|           <div className="mb-2 whitespace-nowrap text-gray-500"> | ||||
|             Remaining balance | ||||
|           </div> | ||||
|           <Row className="gap-4"> | ||||
|             <div>{formatMoney(Math.floor(remainingBalance))}</div> | ||||
|             {user.balance !== 1000 && <AddFundsButton />} | ||||
|         <Col className="gap-3 text-sm"> | ||||
|           {contractIdForLoan && ( | ||||
|             <Row className="items-center justify-between gap-2 text-gray-500"> | ||||
|               <Row className="items-center gap-2"> | ||||
|                 Amount loaned{' '} | ||||
|                 <InfoTooltip | ||||
|                   text={`In every market, you get an interest-free loan on the first ${formatMoney( | ||||
|                     MAX_LOAN_PER_CONTRACT | ||||
|                   )}.`}
 | ||||
|                 /> | ||||
|               </Row> | ||||
|               <span className="text-neutral">{formatMoney(loanAmount)}</span>{' '} | ||||
|             </Row> | ||||
|           )} | ||||
|           <Row className="items-center justify-between gap-2 text-gray-500"> | ||||
|             Remaining balance{' '} | ||||
|             <span className="text-neutral"> | ||||
|               {formatMoney(Math.floor(remainingBalance))} | ||||
|             </span> | ||||
|           </Row> | ||||
|         </Col> | ||||
|       )} | ||||
|  |  | |||
							
								
								
									
										49
									
								
								web/components/analytics/charts.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								web/components/analytics/charts.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | |||
| import { ResponsiveLine } from '@nivo/line' | ||||
| import dayjs from 'dayjs' | ||||
| import _ from 'lodash' | ||||
| import { useWindowSize } from '../../hooks/use-window-size' | ||||
| 
 | ||||
| export function DailyCountChart(props: { | ||||
|   startDate: number | ||||
|   dailyCounts: number[] | ||||
|   small?: boolean | ||||
| }) { | ||||
|   const { dailyCounts, startDate, small } = props | ||||
|   const { width } = useWindowSize() | ||||
| 
 | ||||
|   const dates = dailyCounts.map((_, i) => | ||||
|     dayjs(startDate).add(i, 'day').toDate() | ||||
|   ) | ||||
| 
 | ||||
|   const points = _.zip(dates, dailyCounts).map(([date, betCount]) => ({ | ||||
|     x: date, | ||||
|     y: betCount, | ||||
|   })) | ||||
|   const data = [{ id: 'Count', data: points, color: '#11b981' }] | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className="w-full" | ||||
|       style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} | ||||
|     > | ||||
|       <ResponsiveLine | ||||
|         data={data} | ||||
|         yScale={{ type: 'linear', stacked: false }} | ||||
|         xScale={{ | ||||
|           type: 'time', | ||||
|         }} | ||||
|         axisBottom={{ | ||||
|           format: (date) => dayjs(date).format('MMM DD'), | ||||
|         }} | ||||
|         colors={{ datum: 'color' }} | ||||
|         pointSize={width && width >= 800 ? 10 : 0} | ||||
|         pointBorderWidth={1} | ||||
|         pointBorderColor="#fff" | ||||
|         enableSlices="x" | ||||
|         enableGridX={!!width && width >= 800} | ||||
|         enableArea | ||||
|         margin={{ top: 20, right: 28, bottom: 22, left: 40 }} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | @ -30,8 +30,9 @@ export function AnswerBetPanel(props: { | |||
|   answer: Answer | ||||
|   contract: Contract | ||||
|   closePanel: () => void | ||||
|   className?: string | ||||
| }) { | ||||
|   const { answer, contract, closePanel } = props | ||||
|   const { answer, contract, closePanel, className } = props | ||||
|   const { id: answerId } = answer | ||||
| 
 | ||||
|   const user = useUser() | ||||
|  | @ -48,11 +49,6 @@ export function AnswerBetPanel(props: { | |||
|   async function submitBet() { | ||||
|     if (!user || !betAmount) return | ||||
| 
 | ||||
|     if (user.balance < betAmount) { | ||||
|       setError('Insufficient balance') | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     setError(undefined) | ||||
|     setIsSubmitting(true) | ||||
| 
 | ||||
|  | @ -97,12 +93,12 @@ export function AnswerBetPanel(props: { | |||
|   const currentReturnPercent = (currentReturn * 100).toFixed() + '%' | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="items-start px-2 pb-2 pt-4 sm:pt-0"> | ||||
|       <Row className="self-stretch items-center justify-between"> | ||||
|     <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> | ||||
|       <Row className="items-center justify-between self-stretch"> | ||||
|         <div className="text-xl">Buy this answer</div> | ||||
| 
 | ||||
|         <button className="btn-ghost btn-circle" onClick={closePanel}> | ||||
|           <XIcon className="w-8 h-8 text-gray-500 mx-auto" aria-hidden="true" /> | ||||
|           <XIcon className="mx-auto h-8 w-8 text-gray-500" aria-hidden="true" /> | ||||
|         </button> | ||||
|       </Row> | ||||
|       <div className="my-3 text-left text-sm text-gray-500">Amount </div> | ||||
|  | @ -114,40 +110,44 @@ export function AnswerBetPanel(props: { | |||
|         setError={setError} | ||||
|         disabled={isSubmitting} | ||||
|         inputRef={inputRef} | ||||
|         contractIdForLoan={contract.id} | ||||
|       /> | ||||
|       <Col className="mt-3 w-full gap-3"> | ||||
|         <Row className="items-center justify-between text-sm"> | ||||
|           <div className="text-gray-500">Probability</div> | ||||
|           <Row> | ||||
|             <div>{formatPercent(initialProb)}</div> | ||||
|             <div className="mx-2">→</div> | ||||
|             <div>{formatPercent(resultProb)}</div> | ||||
|           </Row> | ||||
|         </Row> | ||||
| 
 | ||||
|       <Spacer h={4} /> | ||||
| 
 | ||||
|       <div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div> | ||||
|       <Row> | ||||
|         <div>{formatPercent(initialProb)}</div> | ||||
|         <div className="mx-2">→</div> | ||||
|         <div>{formatPercent(resultProb)}</div> | ||||
|       </Row> | ||||
| 
 | ||||
|       <Spacer h={4} /> | ||||
| 
 | ||||
|       <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> | ||||
|         Payout if chosen | ||||
|         <InfoTooltip | ||||
|           text={`Current payout for ${formatWithCommas( | ||||
|             shares | ||||
|           )} / ${formatWithCommas( | ||||
|             shares + contract.totalShares[answerId] | ||||
|           )} shares`}
 | ||||
|         /> | ||||
|       </Row> | ||||
|       <div> | ||||
|         {formatMoney(currentPayout)} | ||||
|           <span>(+{currentReturnPercent})</span> | ||||
|       </div> | ||||
|         <Row className="items-start justify-between gap-2 text-sm"> | ||||
|           <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> | ||||
|             <div>Payout if chosen</div> | ||||
|             <InfoTooltip | ||||
|               text={`Current payout for ${formatWithCommas( | ||||
|                 shares | ||||
|               )} / ${formatWithCommas( | ||||
|                 shares + contract.totalShares[answerId] | ||||
|               )} shares`}
 | ||||
|             /> | ||||
|           </Row> | ||||
|           <Row className="flex-wrap items-end justify-end gap-2"> | ||||
|             <span className="whitespace-nowrap"> | ||||
|               {formatMoney(currentPayout)} | ||||
|             </span> | ||||
|             <span>(+{currentReturnPercent})</span> | ||||
|           </Row> | ||||
|         </Row> | ||||
|       </Col> | ||||
| 
 | ||||
|       <Spacer h={6} /> | ||||
| 
 | ||||
|       {user ? ( | ||||
|         <button | ||||
|           className={clsx( | ||||
|             'btn', | ||||
|             'btn self-stretch', | ||||
|             betDisabled ? 'btn-disabled' : 'btn-primary', | ||||
|             isSubmitting ? 'loading' : '' | ||||
|           )} | ||||
|  | @ -157,7 +157,7 @@ export function AnswerBetPanel(props: { | |||
|         </button> | ||||
|       ) : ( | ||||
|         <button | ||||
|           className="btn mt-4 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" | ||||
|           className="btn self-stretch whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" | ||||
|           onClick={firebaseLogin} | ||||
|         > | ||||
|           Sign in to trade! | ||||
|  |  | |||
|  | @ -97,6 +97,7 @@ export function AnswerItem(props: { | |||
|           answer={answer} | ||||
|           contract={contract} | ||||
|           closePanel={() => setIsBetting(false)} | ||||
|           className="sm:w-72" | ||||
|         /> | ||||
|       ) : ( | ||||
|         <Row className="items-center justify-end gap-4 self-end sm:self-start"> | ||||
|  |  | |||
|  | @ -71,9 +71,9 @@ export function AnswerResolvePanel(props: { | |||
|       : 'btn-disabled' | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="gap-4 p-4 bg-gray-50 rounded"> | ||||
|     <Col className="gap-4 rounded bg-gray-50 p-4"> | ||||
|       <div>Resolve your market</div> | ||||
|       <Col className="sm:flex-row sm:items-center gap-4"> | ||||
|       <Col className="gap-4 sm:flex-row sm:items-center"> | ||||
|         <ChooseCancelSelector | ||||
|           className="sm:!flex-row sm:items-center" | ||||
|           selected={resolveOption} | ||||
|  |  | |||
|  | @ -95,9 +95,9 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) { | |||
|       ))} | ||||
| 
 | ||||
|       {sortedAnswers.length === 0 ? ( | ||||
|         <div className="text-gray-500 p-4">No answers yet...</div> | ||||
|         <div className="p-4 text-gray-500">No answers yet...</div> | ||||
|       ) : ( | ||||
|         <div className="text-gray-500 self-end p-4"> | ||||
|         <div className="self-end p-4 text-gray-500"> | ||||
|           None of the above:{' '} | ||||
|           {formatPercent(getOutcomeProbability(contract.totalShares, '0'))} | ||||
|         </div> | ||||
|  |  | |||
|  | @ -36,6 +36,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { | |||
|   const submitAnswer = async () => { | ||||
|     if (canSubmit) { | ||||
|       setIsSubmitting(true) | ||||
| 
 | ||||
|       const result = await createAnswer({ | ||||
|         contractId: contract.id, | ||||
|         text, | ||||
|  | @ -48,7 +49,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { | |||
|         setText('') | ||||
|         setBetAmount(10) | ||||
|         setAmountError(undefined) | ||||
|       } | ||||
|       } else setAmountError(result.message) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -72,7 +73,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) { | |||
|   const currentReturnPercent = (currentReturn * 100).toFixed() + '%' | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="gap-4 p-4 bg-gray-50 rounded"> | ||||
|     <Col className="gap-4 rounded bg-gray-50 p-4"> | ||||
|       <Col className="flex-1 gap-2"> | ||||
|         <div className="mb-1">Add your answer</div> | ||||
|         <Textarea | ||||
|  | @ -86,14 +87,14 @@ export function CreateAnswerPanel(props: { contract: Contract }) { | |||
|         <div /> | ||||
|         <Col | ||||
|           className={clsx( | ||||
|             'sm:flex-row gap-4', | ||||
|             'gap-4 sm:flex-row sm:items-end', | ||||
|             text ? 'justify-between' : 'self-end' | ||||
|           )} | ||||
|         > | ||||
|           {text && ( | ||||
|             <> | ||||
|               <Col className="gap-2 mt-1"> | ||||
|                 <div className="text-gray-500 text-sm">Buy amount</div> | ||||
|               <Col className="mt-1 gap-2"> | ||||
|                 <div className="text-sm text-gray-500">Buy amount</div> | ||||
|                 <AmountInput | ||||
|                   amount={betAmount} | ||||
|                   onChange={setBetAmount} | ||||
|  | @ -101,34 +102,42 @@ export function CreateAnswerPanel(props: { contract: Contract }) { | |||
|                   setError={setAmountError} | ||||
|                   minimumAmount={1} | ||||
|                   disabled={isSubmitting} | ||||
|                   contractIdForLoan={contract.id} | ||||
|                 /> | ||||
|               </Col> | ||||
|               <Col className="gap-2 mt-1"> | ||||
|                 <div className="text-sm text-gray-500">Implied probability</div> | ||||
|                 <Row> | ||||
|                   <div>{formatPercent(0)}</div> | ||||
|                   <div className="mx-2">→</div> | ||||
|                   <div>{formatPercent(resultProb)}</div> | ||||
|               <Col className="gap-3"> | ||||
|                 <Row className="items-center justify-between text-sm"> | ||||
|                   <div className="text-gray-500">Probability</div> | ||||
|                   <Row> | ||||
|                     <div>{formatPercent(0)}</div> | ||||
|                     <div className="mx-2">→</div> | ||||
|                     <div>{formatPercent(resultProb)}</div> | ||||
|                   </Row> | ||||
|                 </Row> | ||||
|                 <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> | ||||
|                   Payout if chosen | ||||
|                   <InfoTooltip | ||||
|                     text={`Current payout for ${formatWithCommas( | ||||
|                       shares | ||||
|                     )} / ${formatWithCommas(shares)} shares`}
 | ||||
|                   /> | ||||
| 
 | ||||
|                 <Row className="justify-between gap-2 text-sm"> | ||||
|                   <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> | ||||
|                     <div>Payout if chosen</div> | ||||
|                     <InfoTooltip | ||||
|                       text={`Current payout for ${formatWithCommas( | ||||
|                         shares | ||||
|                       )} / ${formatWithCommas(shares)} shares`}
 | ||||
|                     /> | ||||
|                   </Row> | ||||
|                   <Row className="flex-wrap items-end justify-end gap-2"> | ||||
|                     <span className="whitespace-nowrap"> | ||||
|                       {formatMoney(currentPayout)} | ||||
|                     </span> | ||||
|                     <span>(+{currentReturnPercent})</span> | ||||
|                   </Row> | ||||
|                 </Row> | ||||
|                 <div> | ||||
|                   {formatMoney(currentPayout)} | ||||
|                     <span>(+{currentReturnPercent})</span> | ||||
|                 </div> | ||||
|               </Col> | ||||
|             </> | ||||
|           )} | ||||
|           {user ? ( | ||||
|             <button | ||||
|               className={clsx( | ||||
|                 'btn self-end mt-2', | ||||
|                 'btn mt-2', | ||||
|                 canSubmit ? 'btn-outline' : 'btn-disabled', | ||||
|                 isSubmitting && 'loading' | ||||
|               )} | ||||
|  |  | |||
|  | @ -78,11 +78,6 @@ export function BetPanel(props: { | |||
|   async function submitBet() { | ||||
|     if (!user || !betAmount) return | ||||
| 
 | ||||
|     if (user.balance < betAmount) { | ||||
|       setError('Insufficient balance') | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     setError(undefined) | ||||
|     setIsSubmitting(true) | ||||
| 
 | ||||
|  | @ -144,7 +139,6 @@ export function BetPanel(props: { | |||
|         text={panelTitle} | ||||
|       /> | ||||
| 
 | ||||
|       {/* <div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> */} | ||||
|       <YesNoSelector | ||||
|         className="mb-4" | ||||
|         selected={betChoice} | ||||
|  | @ -160,45 +154,49 @@ export function BetPanel(props: { | |||
|         setError={setError} | ||||
|         disabled={isSubmitting} | ||||
|         inputRef={inputRef} | ||||
|         contractIdForLoan={contract.id} | ||||
|       /> | ||||
| 
 | ||||
|       <Spacer h={4} /> | ||||
|       <Col className="mt-3 w-full gap-3"> | ||||
|         <Row className="items-center justify-between text-sm"> | ||||
|           <div className="text-gray-500">Probability</div> | ||||
|           <Row> | ||||
|             <div>{formatPercent(initialProb)}</div> | ||||
|             <div className="mx-2">→</div> | ||||
|             <div>{formatPercent(resultProb)}</div> | ||||
|           </Row> | ||||
|         </Row> | ||||
| 
 | ||||
|       <div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div> | ||||
|       <Row> | ||||
|         <div>{formatPercent(initialProb)}</div> | ||||
|         <div className="mx-2">→</div> | ||||
|         <div>{formatPercent(resultProb)}</div> | ||||
|       </Row> | ||||
| 
 | ||||
|       {betChoice && ( | ||||
|         <> | ||||
|           <Spacer h={4} /> | ||||
|           <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> | ||||
|             Payout if <OutcomeLabel outcome={betChoice} /> | ||||
|         <Row className="items-start justify-between gap-2 text-sm"> | ||||
|           <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> | ||||
|             <div> | ||||
|               Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} /> | ||||
|             </div> | ||||
|             <InfoTooltip | ||||
|               text={`Current payout for ${formatWithCommas( | ||||
|                 shares | ||||
|               )} / ${formatWithCommas( | ||||
|                 shares + | ||||
|                   totalShares[betChoice] - | ||||
|                   (phantomShares ? phantomShares[betChoice] : 0) | ||||
|                   totalShares[betChoice ?? 'YES'] - | ||||
|                   (phantomShares ? phantomShares[betChoice ?? 'YES'] : 0) | ||||
|               )} ${betChoice} shares`}
 | ||||
|             /> | ||||
|           </Row> | ||||
|           <div> | ||||
|             {formatMoney(currentPayout)} | ||||
|               <span>(+{currentReturnPercent})</span> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|           <Row className="flex-wrap items-end justify-end gap-2"> | ||||
|             <span className="whitespace-nowrap"> | ||||
|               {formatMoney(currentPayout)} | ||||
|             </span> | ||||
|             <span>(+{currentReturnPercent})</span> | ||||
|           </Row> | ||||
|         </Row> | ||||
|       </Col> | ||||
| 
 | ||||
|       <Spacer h={6} /> | ||||
|       <Spacer h={8} /> | ||||
| 
 | ||||
|       {user && ( | ||||
|         <button | ||||
|           className={clsx( | ||||
|             'btn', | ||||
|             'btn flex-1', | ||||
|             betDisabled | ||||
|               ? 'btn-disabled' | ||||
|               : betChoice === 'YES' | ||||
|  | @ -213,7 +211,7 @@ export function BetPanel(props: { | |||
|       )} | ||||
|       {user === null && ( | ||||
|         <button | ||||
|           className="btn mt-4 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" | ||||
|           className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" | ||||
|           onClick={firebaseLogin} | ||||
|         > | ||||
|           Sign in to trade! | ||||
|  |  | |||
|  | @ -82,7 +82,8 @@ export function BetsList(props: { user: User }) { | |||
|         if (bet.isSold || bet.sale) return 0 | ||||
| 
 | ||||
|         const contract = contracts.find((c) => c.id === contractId) | ||||
|         return contract ? calculatePayout(contract, bet, 'MKT') : 0 | ||||
|         const payout = contract ? calculatePayout(contract, bet, 'MKT') : 0 | ||||
|         return payout - (bet.loanAmount ?? 0) | ||||
|       }) | ||||
|     } | ||||
|   ) | ||||
|  | @ -126,8 +127,8 @@ export function BetsList(props: { user: User }) { | |||
|   const totalPortfolio = currentBetsValue + user.balance | ||||
| 
 | ||||
|   const totalPnl = totalPortfolio - user.totalDeposits | ||||
|   const totalProfit = (totalPnl / user.totalDeposits) * 100 | ||||
|   const investedProfit = | ||||
|   const totalProfitPercent = (totalPnl / user.totalDeposits) * 100 | ||||
|   const investedProfitPercent = | ||||
|     ((currentBetsValue - currentInvestment) / currentInvestment) * 100 | ||||
| 
 | ||||
|   return ( | ||||
|  | @ -135,17 +136,17 @@ export function BetsList(props: { user: User }) { | |||
|       <Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> | ||||
|         <Row className="gap-8"> | ||||
|           <Col> | ||||
|             <div className="text-sm text-gray-500">Invested</div> | ||||
|             <div className="text-sm text-gray-500">Investment value</div> | ||||
|             <div className="text-lg"> | ||||
|               {formatMoney(currentBetsValue)}{' '} | ||||
|               <ProfitBadge profitPercent={investedProfit} /> | ||||
|               <ProfitBadge profitPercent={investedProfitPercent} /> | ||||
|             </div> | ||||
|           </Col> | ||||
|           <Col> | ||||
|             <div className="text-sm text-gray-500">Total portfolio</div> | ||||
|             <div className="text-sm text-gray-500">Total profit</div> | ||||
|             <div className="text-lg"> | ||||
|               {formatMoney(totalPortfolio)}{' '} | ||||
|               <ProfitBadge profitPercent={totalProfit} /> | ||||
|               {formatMoney(totalPnl)}{' '} | ||||
|               <ProfitBadge profitPercent={totalProfitPercent} /> | ||||
|             </div> | ||||
|           </Col> | ||||
|         </Row> | ||||
|  | @ -457,6 +458,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { | |||
|     shares, | ||||
|     isSold, | ||||
|     isAnte, | ||||
|     loanAmount, | ||||
|   } = bet | ||||
| 
 | ||||
|   const { isResolved, closeTime } = contract | ||||
|  | @ -464,7 +466,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { | |||
| 
 | ||||
|   const saleAmount = saleBet?.sale?.amount | ||||
| 
 | ||||
|   const saleDisplay = bet.isAnte ? ( | ||||
|   const saleDisplay = isAnte ? ( | ||||
|     'ANTE' | ||||
|   ) : saleAmount !== undefined ? ( | ||||
|     <>{formatMoney(saleAmount)} (sold)</> | ||||
|  | @ -491,7 +493,10 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { | |||
|       <td> | ||||
|         <OutcomeLabel outcome={outcome} /> | ||||
|       </td> | ||||
|       <td>{formatMoney(amount)}</td> | ||||
|       <td> | ||||
|         {formatMoney(amount)} | ||||
|         {loanAmount ? ` (${formatMoney(loanAmount ?? 0)} loan)` : ''} | ||||
|       </td> | ||||
|       <td>{saleDisplay}</td> | ||||
|       {!isResolved && <td>{payoutIfChosenDisplay}</td>} | ||||
|       <td> | ||||
|  | @ -510,21 +515,23 @@ function SellButton(props: { contract: Contract; bet: Bet }) { | |||
|   }, []) | ||||
| 
 | ||||
|   const { contract, bet } = props | ||||
|   const isBinary = contract.outcomeType === 'BINARY' | ||||
|   const { outcome, shares, loanAmount } = bet | ||||
| 
 | ||||
|   const [isSubmitting, setIsSubmitting] = useState(false) | ||||
| 
 | ||||
|   const initialProb = getOutcomeProbability( | ||||
|     contract.totalShares, | ||||
|     bet.outcome === 'NO' ? 'YES' : bet.outcome | ||||
|     outcome === 'NO' ? 'YES' : outcome | ||||
|   ) | ||||
| 
 | ||||
|   const outcomeProb = getProbabilityAfterSale( | ||||
|     contract.totalShares, | ||||
|     bet.outcome, | ||||
|     bet.shares | ||||
|     outcome, | ||||
|     shares | ||||
|   ) | ||||
| 
 | ||||
|   const saleAmount = calculateSaleAmount(contract, bet) | ||||
|   const profit = saleAmount - bet.amount | ||||
| 
 | ||||
|   return ( | ||||
|     <ConfirmationButton | ||||
|  | @ -533,7 +540,7 @@ function SellButton(props: { contract: Contract; bet: Bet }) { | |||
|         className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), | ||||
|         label: 'Sell', | ||||
|       }} | ||||
|       submitBtn={{ className: 'btn-primary' }} | ||||
|       submitBtn={{ className: 'btn-primary', label: 'Sell' }} | ||||
|       onSubmit={async () => { | ||||
|         setIsSubmitting(true) | ||||
|         await sellBet({ contractId: contract.id, betId: bet.id }) | ||||
|  | @ -541,17 +548,21 @@ function SellButton(props: { contract: Contract; bet: Bet }) { | |||
|       }} | ||||
|     > | ||||
|       <div className="mb-4 text-2xl"> | ||||
|         Sell <OutcomeLabel outcome={bet.outcome} /> | ||||
|       </div> | ||||
|       <div> | ||||
|         Do you want to sell {formatWithCommas(bet.shares)} shares of{' '} | ||||
|         <OutcomeLabel outcome={bet.outcome} /> for {formatMoney(saleAmount)}? | ||||
|         Sell {formatWithCommas(shares)} shares of{' '} | ||||
|         <OutcomeLabel outcome={outcome} /> for {formatMoney(saleAmount)}? | ||||
|       </div> | ||||
|       {!!loanAmount && ( | ||||
|         <div className="mt-2"> | ||||
|           You will also pay back {formatMoney(loanAmount)} of your loan, for a | ||||
|           net of {formatMoney(saleAmount - loanAmount)}. | ||||
|         </div> | ||||
|       )} | ||||
| 
 | ||||
|       <div className="mt-2 mb-1 text-sm text-gray-500"> | ||||
|         ({isBinary ? 'Updated' : <OutcomeLabel outcome={bet.outcome} />}{' '} | ||||
|         probability: {formatPercent(initialProb)} → {formatPercent(outcomeProb)} | ||||
|         ) | ||||
|       <div className="mt-2 mb-1 text-sm"> | ||||
|         {profit > 0 ? 'Profit' : 'Loss'}: {formatMoney(profit).replace('-', '')} | ||||
|         <br /> | ||||
|         Market probability: {formatPercent(initialProb)} →{' '} | ||||
|         {formatPercent(outcomeProb)} | ||||
|       </div> | ||||
|     </ConfirmationButton> | ||||
|   ) | ||||
|  |  | |||
							
								
								
									
										13
									
								
								web/components/client-render.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/components/client-render.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| // Adapted from https://stackoverflow.com/a/50884055/1222351
 | ||||
| import { useEffect, useState } from 'react' | ||||
| 
 | ||||
| export function ClientRender(props: { children: React.ReactNode }) { | ||||
|   const { children } = props | ||||
| 
 | ||||
|   const [mounted, setMounted] = useState(false) | ||||
|   useEffect(() => { | ||||
|     setMounted(true) | ||||
|   }, []) | ||||
| 
 | ||||
|   return mounted ? <>{children}</> : null | ||||
| } | ||||
|  | @ -150,7 +150,8 @@ function AbbrContractDetails(props: { | |||
|         ) : showCloseTime ? ( | ||||
|           <Row className="gap-1"> | ||||
|             <ClockIcon className="h-5 w-5" /> | ||||
|             Closes {fromNow(closeTime || 0)} | ||||
|             {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} | ||||
|             {fromNow(closeTime || 0)} | ||||
|           </Row> | ||||
|         ) : ( | ||||
|           <Row className="gap-1"> | ||||
|  | @ -312,7 +313,7 @@ function EditableCloseDate(props: { | |||
|             className="btn btn-xs btn-ghost" | ||||
|             onClick={() => setIsEditingCloseTime(true)} | ||||
|           > | ||||
|             <PencilIcon className="inline h-4 w-4 mr-2" /> Edit | ||||
|             <PencilIcon className="mr-2 inline h-4 w-4" /> Edit | ||||
|           </button> | ||||
|         ))} | ||||
|     </> | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ import { | |||
| import { useUser } from '../hooks/use-user' | ||||
| import { Linkify } from './linkify' | ||||
| import { Row } from './layout/row' | ||||
| import { createComment } from '../lib/firebase/comments' | ||||
| import { createComment, MAX_COMMENT_LENGTH } from '../lib/firebase/comments' | ||||
| import { useComments } from '../hooks/use-comments' | ||||
| import { formatMoney } from '../../common/util/format' | ||||
| import { ResolutionOrChance } from './contract-card' | ||||
|  | @ -135,8 +135,9 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) { | |||
|                 className="textarea textarea-bordered w-full" | ||||
|                 placeholder="Add a comment..." | ||||
|                 rows={3} | ||||
|                 maxLength={MAX_COMMENT_LENGTH} | ||||
|                 onKeyDown={(e) => { | ||||
|                   if (e.key === 'Enter' && e.ctrlKey) { | ||||
|                   if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { | ||||
|                     submitComment() | ||||
|                   } | ||||
|                 }} | ||||
|  | @ -181,7 +182,7 @@ function EditContract(props: { | |||
|           e.target.setSelectionRange(text.length, text.length) | ||||
|         } | ||||
|         onKeyDown={(e) => { | ||||
|           if (e.key === 'Enter' && e.ctrlKey) { | ||||
|           if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { | ||||
|             onSave(text) | ||||
|           } | ||||
|         }} | ||||
|  | @ -289,7 +290,10 @@ function TruncatedComment(props: { | |||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="mt-2 whitespace-pre-line break-words text-gray-700"> | ||||
|     <div | ||||
|       className="mt-2 whitespace-pre-line break-words text-gray-700" | ||||
|       style={{ fontSize: 15 }} | ||||
|     > | ||||
|       <Linkify text={truncated} /> | ||||
|       {truncated != comment && ( | ||||
|         <SiteLink href={moreHref} className="text-indigo-700"> | ||||
|  | @ -300,8 +304,11 @@ function TruncatedComment(props: { | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| function FeedQuestion(props: { contract: Contract }) { | ||||
|   const { contract } = props | ||||
| function FeedQuestion(props: { | ||||
|   contract: Contract | ||||
|   showDescription?: boolean | ||||
| }) { | ||||
|   const { contract, showDescription } = props | ||||
|   const { creatorName, creatorUsername, question, resolution, outcomeType } = | ||||
|     contract | ||||
|   const { truePool } = contractMetrics(contract) | ||||
|  | @ -336,22 +343,34 @@ function FeedQuestion(props: { contract: Contract }) { | |||
|             {closeMessage} | ||||
|           </span> | ||||
|         </div> | ||||
|         <Col className="mb-4 items-start justify-between gap-2 sm:flex-row sm:gap-4"> | ||||
|           <SiteLink | ||||
|             href={contractPath(contract)} | ||||
|             className="text-lg text-indigo-700 sm:text-xl" | ||||
|           > | ||||
|             {question} | ||||
|           </SiteLink> | ||||
|         <Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4"> | ||||
|           <Col> | ||||
|             <SiteLink | ||||
|               href={contractPath(contract)} | ||||
|               className="text-lg text-indigo-700 sm:text-xl" | ||||
|             > | ||||
|               {question} | ||||
|             </SiteLink> | ||||
|             {!showDescription && ( | ||||
|               <SiteLink | ||||
|                 href={contractPath(contract)} | ||||
|                 className="relative top-4 self-end text-sm sm:self-start" | ||||
|               > | ||||
|                 <div className="pb-1.5 text-gray-500">See more...</div> | ||||
|               </SiteLink> | ||||
|             )} | ||||
|           </Col> | ||||
|           {(isBinary || resolution) && ( | ||||
|             <ResolutionOrChance className="items-center" contract={contract} /> | ||||
|           )} | ||||
|         </Col> | ||||
|         <TruncatedComment | ||||
|           comment={contract.description} | ||||
|           moreHref={contractPath(contract)} | ||||
|           shouldTruncate | ||||
|         /> | ||||
|         {showDescription && ( | ||||
|           <TruncatedComment | ||||
|             comment={contract.description} | ||||
|             moreHref={contractPath(contract)} | ||||
|             shouldTruncate | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
|  | @ -680,6 +699,7 @@ type ActivityItem = { | |||
|     | 'close' | ||||
|     | 'resolve' | ||||
|     | 'expand' | ||||
|     | undefined | ||||
| } | ||||
| 
 | ||||
| type FeedType = | ||||
|  | @ -690,64 +710,24 @@ type FeedType = | |||
|   // Grouped for a multi-category outcome
 | ||||
|   | 'multi' | ||||
| 
 | ||||
| export function ContractFeed(props: { | ||||
| function FeedItems(props: { | ||||
|   contract: Contract | ||||
|   bets: Bet[] | ||||
|   comments: Comment[] | ||||
|   items: ActivityItem[] | ||||
|   feedType: FeedType | ||||
|   setExpanded: (expanded: boolean) => void | ||||
|   outcome?: string // Which multi-category outcome to filter
 | ||||
|   betRowClassName?: string | ||||
| }) { | ||||
|   const { contract, feedType, outcome, betRowClassName } = props | ||||
|   const { id, outcomeType } = contract | ||||
|   const { contract, items, feedType, outcome, setExpanded, betRowClassName } = | ||||
|     props | ||||
|   const { outcomeType } = contract | ||||
|   const isBinary = outcomeType === 'BINARY' | ||||
| 
 | ||||
|   const [expanded, setExpanded] = useState(false) | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   let bets = useBets(contract.id) ?? props.bets | ||||
|   bets = isBinary | ||||
|     ? bets.filter((bet) => !bet.isAnte) | ||||
|     : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) | ||||
| 
 | ||||
|   if (feedType === 'multi') { | ||||
|     bets = bets.filter((bet) => bet.outcome === outcome) | ||||
|   } | ||||
| 
 | ||||
|   const comments = useComments(id) ?? props.comments | ||||
| 
 | ||||
|   const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS | ||||
| 
 | ||||
|   const allItems = [ | ||||
|     { type: 'start', id: 0 }, | ||||
|     ...groupBets(bets, comments, groupWindow, user?.id), | ||||
|   ] | ||||
|   if (contract.closeTime && contract.closeTime <= Date.now()) { | ||||
|     allItems.push({ type: 'close', id: `${contract.closeTime}` }) | ||||
|   } | ||||
|   if (contract.resolution) { | ||||
|     allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) | ||||
|   } | ||||
|   if (feedType === 'multi') { | ||||
|     // Hack to add some more padding above the 'multi' feedType, by adding a null item
 | ||||
|     allItems.unshift({ type: '', id: -1 }) | ||||
|   } | ||||
| 
 | ||||
|   // If there are more than 5 items, only show the first, an expand item, and last 3
 | ||||
|   let items = allItems | ||||
|   if (!expanded && allItems.length > 5 && feedType == 'activity') { | ||||
|     items = [ | ||||
|       allItems[0], | ||||
|       { type: 'expand', id: 'expand' }, | ||||
|       ...allItems.slice(-3), | ||||
|     ] | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="flow-root pr-2 md:pr-0"> | ||||
|       <div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}> | ||||
|       <div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> | ||||
|         {items.map((activityItem, activityItemIdx) => ( | ||||
|           <div key={activityItem.id} className="relative pb-8"> | ||||
|           <div key={activityItem.id} className="relative pb-6"> | ||||
|             {activityItemIdx !== items.length - 1 ? ( | ||||
|               <span | ||||
|                 className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" | ||||
|  | @ -791,6 +771,117 @@ export function ContractFeed(props: { | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function ContractFeed(props: { | ||||
|   contract: Contract | ||||
|   bets: Bet[] | ||||
|   comments: Comment[] | ||||
|   feedType: FeedType | ||||
|   outcome?: string // Which multi-category outcome to filter
 | ||||
|   betRowClassName?: string | ||||
| }) { | ||||
|   const { contract, feedType, outcome, betRowClassName } = props | ||||
|   const { id, outcomeType } = contract | ||||
|   const isBinary = outcomeType === 'BINARY' | ||||
| 
 | ||||
|   const [expanded, setExpanded] = useState(false) | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   let bets = useBets(contract.id) ?? props.bets | ||||
|   bets = isBinary | ||||
|     ? bets.filter((bet) => !bet.isAnte) | ||||
|     : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) | ||||
| 
 | ||||
|   if (feedType === 'multi') { | ||||
|     bets = bets.filter((bet) => bet.outcome === outcome) | ||||
|   } | ||||
| 
 | ||||
|   const comments = useComments(id) ?? props.comments | ||||
| 
 | ||||
|   const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS | ||||
| 
 | ||||
|   const allItems: ActivityItem[] = [ | ||||
|     { type: 'start', id: '0' }, | ||||
|     ...groupBets(bets, comments, groupWindow, user?.id), | ||||
|   ] | ||||
|   if (contract.closeTime && contract.closeTime <= Date.now()) { | ||||
|     allItems.push({ type: 'close', id: `${contract.closeTime}` }) | ||||
|   } | ||||
|   if (contract.resolution) { | ||||
|     allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) | ||||
|   } | ||||
|   if (feedType === 'multi') { | ||||
|     // Hack to add some more padding above the 'multi' feedType, by adding a null item
 | ||||
|     allItems.unshift({ type: undefined, id: '-1' }) | ||||
|   } | ||||
| 
 | ||||
|   // If there are more than 5 items, only show the first, an expand item, and last 3
 | ||||
|   let items = allItems | ||||
|   if (!expanded && allItems.length > 5 && feedType == 'activity') { | ||||
|     items = [ | ||||
|       allItems[0], | ||||
|       { type: 'expand', id: 'expand' }, | ||||
|       ...allItems.slice(-3), | ||||
|     ] | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <FeedItems | ||||
|       contract={contract} | ||||
|       items={items} | ||||
|       feedType={feedType} | ||||
|       setExpanded={setExpanded} | ||||
|       betRowClassName={betRowClassName} | ||||
|       outcome={outcome} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function ContractActivityFeed(props: { | ||||
|   contract: Contract | ||||
|   bets: Bet[] | ||||
|   comments: Comment[] | ||||
|   betRowClassName?: string | ||||
| }) { | ||||
|   const { contract, betRowClassName, bets, comments } = props | ||||
| 
 | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   bets.sort((b1, b2) => b1.createdTime - b2.createdTime) | ||||
|   comments.sort((c1, c2) => c1.createdTime - c2.createdTime) | ||||
| 
 | ||||
|   const allItems: ActivityItem[] = [ | ||||
|     { type: 'start', id: '0' }, | ||||
|     ...groupBets(bets, comments, DAY_IN_MS, user?.id), | ||||
|   ] | ||||
|   if (contract.closeTime && contract.closeTime <= Date.now()) { | ||||
|     allItems.push({ type: 'close', id: `${contract.closeTime}` }) | ||||
|   } | ||||
|   if (contract.resolution) { | ||||
|     allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) | ||||
|   } | ||||
| 
 | ||||
|   // Remove all but last bet group.
 | ||||
|   const betGroups = allItems.filter((item) => item.type === 'betgroup') | ||||
|   const lastBetGroup = betGroups[betGroups.length - 1] | ||||
|   const filtered = allItems.filter( | ||||
|     (item) => item.type !== 'betgroup' || item.id === lastBetGroup?.id | ||||
|   ) | ||||
| 
 | ||||
|   // Only show the first item plus the last three items.
 | ||||
|   const items = | ||||
|     filtered.length > 3 ? [filtered[0], ...filtered.slice(-3)] : filtered | ||||
| 
 | ||||
|   return ( | ||||
|     <FeedItems | ||||
|       contract={contract} | ||||
|       items={items} | ||||
|       feedType="activity" | ||||
|       setExpanded={() => {}} | ||||
|       betRowClassName={betRowClassName} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function ContractSummaryFeed(props: { | ||||
|   contract: Contract | ||||
|   betRowClassName?: string | ||||
|  | @ -804,7 +895,7 @@ export function ContractSummaryFeed(props: { | |||
|       <div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}> | ||||
|         <div className="relative pb-8"> | ||||
|           <div className="relative flex items-start space-x-3"> | ||||
|             <FeedQuestion contract={contract} /> | ||||
|             <FeedQuestion contract={contract} showDescription /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  |  | |||
|  | @ -83,7 +83,7 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) { | |||
|           format: (time) => formatTime(+time, lessThanAWeek), | ||||
|         }} | ||||
|         colors={{ datum: 'color' }} | ||||
|         pointSize={10} | ||||
|         pointSize={bets.length > 100 ? 0 : 10} | ||||
|         pointBorderWidth={1} | ||||
|         pointBorderColor="#fff" | ||||
|         enableSlices="x" | ||||
|  |  | |||
|  | @ -229,11 +229,13 @@ export function SearchableGrid(props: { | |||
|     ) | ||||
|   } else if (sort === 'oldest') { | ||||
|     matches.sort((a, b) => a.createdTime - b.createdTime) | ||||
|   } else if (sort === 'close-date') { | ||||
|   } else if (sort === 'close-date' || sort === 'closed') { | ||||
|     matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) | ||||
|     matches = _.sortBy(matches, (contract) => contract.closeTime) | ||||
|     // Hide contracts that have already closed
 | ||||
|     matches = matches.filter(({ closeTime }) => (closeTime || 0) > Date.now()) | ||||
|     const hideClosed = sort === 'closed' | ||||
|     matches = matches.filter( | ||||
|       ({ closeTime }) => closeTime && closeTime > Date.now() !== hideClosed | ||||
|     ) | ||||
|   } else if (sort === 'most-traded') { | ||||
|     matches.sort( | ||||
|       (a, b) => contractMetrics(b).truePool - contractMetrics(a).truePool | ||||
|  | @ -272,6 +274,7 @@ export function SearchableGrid(props: { | |||
|           <option value="most-traded">Most traded</option> | ||||
|           <option value="24-hour-vol">24h volume</option> | ||||
|           <option value="close-date">Closing soon</option> | ||||
|           <option value="closed">Closed</option> | ||||
|           <option value="newest">Newest</option> | ||||
|           <option value="oldest">Oldest</option> | ||||
| 
 | ||||
|  | @ -289,7 +292,7 @@ export function SearchableGrid(props: { | |||
|       ) : ( | ||||
|         <ContractsGrid | ||||
|           contracts={matches} | ||||
|           showCloseTime={sort == 'close-date'} | ||||
|           showCloseTime={['close-date', 'closed'].includes(sort)} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import dayjs from 'dayjs' | |||
| import utc from 'dayjs/plugin/utc' | ||||
| import timezone from 'dayjs/plugin/timezone' | ||||
| import advanced from 'dayjs/plugin/advancedFormat' | ||||
| import { ClientRender } from './client-render' | ||||
| 
 | ||||
| dayjs.extend(utc) | ||||
| dayjs.extend(timezone) | ||||
|  | @ -19,13 +20,15 @@ export function DateTimeTooltip(props: { | |||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <span | ||||
|         className="tooltip hidden cursor-default sm:inline-block" | ||||
|         data-tip={toolTip} | ||||
|       > | ||||
|         {props.children} | ||||
|       </span> | ||||
|       <span className="sm:hidden whitespace-nowrap">{props.children}</span> | ||||
|       <ClientRender> | ||||
|         <span | ||||
|           className="tooltip hidden cursor-default sm:inline-block" | ||||
|           data-tip={toolTip} | ||||
|         > | ||||
|           {props.children} | ||||
|         </span> | ||||
|       </ClientRender> | ||||
|       <span className="whitespace-nowrap sm:hidden">{props.children}</span> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -88,7 +88,7 @@ export const FastFoldFollowing = (props: { | |||
|         user={user} | ||||
|         followedFoldSlugs={followedFoldSlugs} | ||||
|         folds={[ | ||||
|           { name: 'Politics', slug: 'politics' }, | ||||
|           { name: 'Russia/Ukraine', slug: 'russia-ukraine' }, | ||||
|           { name: 'Crypto', slug: 'crypto' }, | ||||
|           { name: 'Sports', slug: 'sports' }, | ||||
|           { name: 'Science', slug: 'science' }, | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { SparklesIcon, XIcon } from '@heroicons/react/solid' | ||||
| import { Avatar } from './avatar' | ||||
| import { useEffect, useRef, useState } from 'react' | ||||
| import { useRef, useState } from 'react' | ||||
| import { Spacer } from './layout/spacer' | ||||
| import { NewContract } from '../pages/create' | ||||
| import { firebaseLogin, User } from '../lib/firebase/users' | ||||
|  | @ -7,44 +8,51 @@ import { ContractsGrid } from './contracts-list' | |||
| import { Contract } from '../../common/contract' | ||||
| import { Col } from './layout/col' | ||||
| import clsx from 'clsx' | ||||
| import { Row } from './layout/row' | ||||
| 
 | ||||
| export function FeedPromo(props: { hotContracts: Contract[] }) { | ||||
|   const { hotContracts } = props | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Col className="w-full bg-white p-6 sm:rounded-lg"> | ||||
|       <Col className="m-6 mb-1 text-center sm:m-12"> | ||||
|         <h1 className="mt-4 text-4xl sm:mt-5 sm:text-6xl lg:mt-6 xl:text-6xl"> | ||||
|           <div className="mb-2">Create your own</div> | ||||
|           <div className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text  font-bold text-transparent"> | ||||
|             prediction markets | ||||
|           <div className="font-semibold sm:mb-2"> | ||||
|             A{' '} | ||||
|             <span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent"> | ||||
|               market{' '} | ||||
|             </span> | ||||
|             for | ||||
|           </div> | ||||
|           <div className="font-semibold"> | ||||
|             every{' '} | ||||
|             <span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent"> | ||||
|               prediction | ||||
|             </span> | ||||
|           </div> | ||||
|         </h1> | ||||
|         <Spacer h={6} /> | ||||
|         <div className="mb-4 text-gray-500"> | ||||
|           Find prediction markets run by your favorite creators, or make your | ||||
|           own. | ||||
|           Find prediction markets on any topic imaginable. Or create your own! | ||||
|           <br /> | ||||
|           Sign up to get M$ 1,000 for free and start trading! | ||||
|           Sign up to get M$ 1,000 and start trading. | ||||
|           <br /> | ||||
|         </div> | ||||
|         <Spacer h={6} /> | ||||
|         <button | ||||
|           className="btn btn-lg self-center border-none bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600" | ||||
|           className="btn btn-lg self-center border-none bg-gradient-to-r from-teal-500 to-green-500  normal-case hover:from-teal-600 hover:to-green-600" | ||||
|           onClick={firebaseLogin} | ||||
|         > | ||||
|           Sign up now | ||||
|           Sign up for free | ||||
|         </button>{' '} | ||||
|       </Col> | ||||
| 
 | ||||
|       <Spacer h={6} /> | ||||
|       {/*  | ||||
|       <TagsList | ||||
|         className="mt-2" | ||||
|         tags={['#politics', '#crypto', '#covid', '#sports', '#meta']} | ||||
|       /> | ||||
|       <Spacer h={6} /> */} | ||||
|       <Spacer h={12} /> | ||||
| 
 | ||||
|       <Row className="m-4 mb-6 items-center gap-1 text-xl font-semibold text-gray-800"> | ||||
|         <SparklesIcon className="inline h-5 w-5" aria-hidden="true" /> | ||||
|         Trending today | ||||
|       </Row> | ||||
|       <ContractsGrid | ||||
|         contracts={hotContracts?.slice(0, 10) || []} | ||||
|         showHotVolume | ||||
|  | @ -61,7 +69,8 @@ export default function FeedCreate(props: { | |||
| }) { | ||||
|   const { user, tag, className } = props | ||||
|   const [question, setQuestion] = useState('') | ||||
|   const [focused, setFocused] = useState(false) | ||||
|   const [isExpanded, setIsExpanded] = useState(false) | ||||
|   const inputRef = useRef<HTMLTextAreaElement | null>() | ||||
| 
 | ||||
|   const placeholders = [ | ||||
|     'Will anyone I know get engaged this year?', | ||||
|  | @ -78,60 +87,60 @@ export default function FeedCreate(props: { | |||
|   ) | ||||
|   const placeholder = props.placeholder ?? `e.g. ${placeholders[randIndex]}` | ||||
| 
 | ||||
|   const panelRef = useRef<HTMLElement | null>() | ||||
|   const inputRef = useRef<HTMLTextAreaElement | null>() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const onClick = () => { | ||||
|       if ( | ||||
|         panelRef.current && | ||||
|         document.activeElement && | ||||
|         !panelRef.current.contains(document.activeElement) | ||||
|       ) | ||||
|         setFocused(false) | ||||
|     } | ||||
|     window.addEventListener('click', onClick) | ||||
|     return () => window.removeEventListener('click', onClick) | ||||
|   }) | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className={clsx( | ||||
|         'mt-2 w-full rounded bg-white p-4 shadow-md', | ||||
|         question || focused ? 'ring-2 ring-indigo-300' : '', | ||||
|         'mt-2 w-full cursor-text rounded bg-white p-4 shadow-md', | ||||
|         isExpanded ? 'ring-2 ring-indigo-300' : '', | ||||
|         className | ||||
|       )} | ||||
|       onClick={() => !focused && inputRef.current?.focus()} | ||||
|       ref={(elem) => (panelRef.current = elem)} | ||||
|       onClick={() => { | ||||
|         !isExpanded && inputRef.current?.focus() | ||||
|       }} | ||||
|     > | ||||
|       <div className="relative flex items-start space-x-3"> | ||||
|         <Avatar username={user?.username} avatarUrl={user?.avatarUrl} noLink /> | ||||
| 
 | ||||
|         <div className="min-w-0 flex-1"> | ||||
|           {/* TODO: Show focus, for accessibility */} | ||||
|           <div> | ||||
|           <Row className="justify-between"> | ||||
|             <p className="my-0.5 text-sm">Ask a question... </p> | ||||
|           </div> | ||||
|             {isExpanded && ( | ||||
|               <button | ||||
|                 className="btn btn-xs btn-circle btn-ghost rounded" | ||||
|                 onClick={() => setIsExpanded(false)} | ||||
|               > | ||||
|                 <XIcon | ||||
|                   className="mx-auto h-6 w-6 text-gray-500" | ||||
|                   aria-hidden="true" | ||||
|                 /> | ||||
|               </button> | ||||
|             )} | ||||
|           </Row> | ||||
|           <textarea | ||||
|             ref={inputRef as any} | ||||
|             className="w-full resize-none appearance-none border-transparent bg-transparent p-0 text-lg text-indigo-700 placeholder:text-gray-400 focus:border-transparent focus:ring-transparent sm:text-xl" | ||||
|             className={clsx( | ||||
|               'w-full resize-none appearance-none border-transparent bg-transparent p-0 text-indigo-700 placeholder:text-gray-400 focus:border-transparent focus:ring-transparent', | ||||
|               question && 'text-lg sm:text-xl', | ||||
|               !question && 'text-base sm:text-lg' | ||||
|             )} | ||||
|             placeholder={placeholder} | ||||
|             value={question} | ||||
|             rows={question.length > 68 ? 4 : 2} | ||||
|             onClick={(e) => e.stopPropagation()} | ||||
|             onChange={(e) => setQuestion(e.target.value.replace('\n', ''))} | ||||
|             onFocus={() => setFocused(true)} | ||||
|             onFocus={() => setIsExpanded(true)} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       {/* Hide component instead of deleting, so edits to NewContract don't get lost */} | ||||
|       <div className={question || focused ? '' : 'hidden'}> | ||||
|       <div className={isExpanded ? '' : 'hidden'}> | ||||
|         <NewContract question={question} tag={tag} /> | ||||
|       </div> | ||||
| 
 | ||||
|       {/* Show a fake "Create Market" button, which gets replaced with the NewContract one*/} | ||||
|       {!(question || focused) && ( | ||||
|         <div className="flex justify-end"> | ||||
|       {!isExpanded && ( | ||||
|         <div className="flex justify-end sm:-mt-4"> | ||||
|           <button className="btn btn-sm" disabled> | ||||
|             Create Market | ||||
|           </button> | ||||
|  |  | |||
|  | @ -4,11 +4,11 @@ import { useMemo, useRef } from 'react' | |||
| import { Fold } from '../../common/fold' | ||||
| import { User } from '../../common/user' | ||||
| import { filterDefined } from '../../common/util/array' | ||||
| import { Bet, getRecentBets } from '../lib/firebase/bets' | ||||
| import { Bet } from '../lib/firebase/bets' | ||||
| import { Comment, getRecentComments } from '../lib/firebase/comments' | ||||
| import { Contract, getActiveContracts } from '../lib/firebase/contracts' | ||||
| import { listAllFolds } from '../lib/firebase/folds' | ||||
| import { findActiveContracts } from '../pages/activity' | ||||
| import { findActiveContracts } from '../components/activity-feed' | ||||
| import { useInactiveContracts } from './use-contracts' | ||||
| import { useFollowedFolds } from './use-fold' | ||||
| import { useUserBetContracts } from './use-user-bets' | ||||
|  | @ -20,12 +20,9 @@ export const getAllContractInfo = async () => { | |||
|     listAllFolds().catch(() => []), | ||||
|   ]) | ||||
| 
 | ||||
|   const [recentBets, recentComments] = await Promise.all([ | ||||
|     getRecentBets(), | ||||
|     getRecentComments(), | ||||
|   ]) | ||||
|   const recentComments = await getRecentComments() | ||||
| 
 | ||||
|   return { contracts, recentBets, recentComments, folds } | ||||
|   return { contracts, recentComments, folds } | ||||
| } | ||||
| 
 | ||||
| export const useFilterYourContracts = ( | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ export type Sort = | |||
|   | 'most-traded' | ||||
|   | '24-hour-vol' | ||||
|   | 'close-date' | ||||
|   | 'closed' | ||||
|   | 'resolved' | ||||
|   | 'all' | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,10 @@ | |||
| import _ from 'lodash' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { Bet, listenForUserBets } from '../lib/firebase/bets' | ||||
| import { | ||||
|   Bet, | ||||
|   listenForUserBets, | ||||
|   listenForUserContractBets, | ||||
| } from '../lib/firebase/bets' | ||||
| 
 | ||||
| export const useUserBets = (userId: string | undefined) => { | ||||
|   const [bets, setBets] = useState<Bet[] | undefined>(undefined) | ||||
|  | @ -12,6 +16,20 @@ export const useUserBets = (userId: string | undefined) => { | |||
|   return bets | ||||
| } | ||||
| 
 | ||||
| export const useUserContractBets = ( | ||||
|   userId: string | undefined, | ||||
|   contractId: string | undefined | ||||
| ) => { | ||||
|   const [bets, setBets] = useState<Bet[] | undefined>(undefined) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (userId && contractId) | ||||
|       return listenForUserContractBets(userId, contractId, setBets) | ||||
|   }, [userId, contractId]) | ||||
| 
 | ||||
|   return bets | ||||
| } | ||||
| 
 | ||||
| export const useUserBetContracts = (userId: string | undefined) => { | ||||
|   const [contractIds, setContractIds] = useState<string[] | undefined>() | ||||
| 
 | ||||
|  |  | |||
|  | @ -74,6 +74,21 @@ export function listenForUserBets( | |||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function listenForUserContractBets( | ||||
|   userId: string, | ||||
|   contractId: string, | ||||
|   setBets: (bets: Bet[]) => void | ||||
| ) { | ||||
|   const betsQuery = query( | ||||
|     collection(db, 'contracts', contractId, 'bets'), | ||||
|     where('userId', '==', userId) | ||||
|   ) | ||||
|   return listenForValues<Bet>(betsQuery, (bets) => { | ||||
|     bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) | ||||
|     setBets(bets) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function withoutAnteBets(contract: Contract, bets?: Bet[]) { | ||||
|   const { createdTime } = contract | ||||
| 
 | ||||
|  | @ -88,3 +103,24 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) { | |||
| 
 | ||||
|   return bets?.filter((bet) => !bet.isAnte) ?? [] | ||||
| } | ||||
| 
 | ||||
| const getBetsQuery = (startTime: number, endTime: number) => | ||||
|   query( | ||||
|     collectionGroup(db, 'bets'), | ||||
|     where('createdTime', '>=', startTime), | ||||
|     where('createdTime', '<', endTime), | ||||
|     orderBy('createdTime', 'asc') | ||||
|   ) | ||||
| 
 | ||||
| export async function getDailyBets(startTime: number, numberOfDays: number) { | ||||
|   const query = getBetsQuery(startTime, startTime + DAY_IN_MS * numberOfDays) | ||||
|   const bets = await getValues<Bet>(query) | ||||
| 
 | ||||
|   const betsByDay = _.range(0, numberOfDays).map(() => [] as Bet[]) | ||||
|   for (const bet of bets) { | ||||
|     const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_IN_MS) | ||||
|     betsByDay[dayIndex].push(bet) | ||||
|   } | ||||
| 
 | ||||
|   return betsByDay | ||||
| } | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { | |||
|   where, | ||||
|   orderBy, | ||||
| } from 'firebase/firestore' | ||||
| import _ from 'lodash' | ||||
| 
 | ||||
| import { getValues, listenForValues } from './utils' | ||||
| import { db } from './init' | ||||
|  | @ -14,6 +15,8 @@ import { User } from '../../../common/user' | |||
| import { Comment } from '../../../common/comment' | ||||
| export type { Comment } | ||||
| 
 | ||||
| export const MAX_COMMENT_LENGTH = 10000 | ||||
| 
 | ||||
| export async function createComment( | ||||
|   contractId: string, | ||||
|   betId: string, | ||||
|  | @ -27,7 +30,7 @@ export async function createComment( | |||
|     contractId, | ||||
|     betId, | ||||
|     userId: commenter.id, | ||||
|     text, | ||||
|     text: text.slice(0, MAX_COMMENT_LENGTH), | ||||
|     createdTime: Date.now(), | ||||
|     userName: commenter.name, | ||||
|     userUsername: commenter.username, | ||||
|  | @ -87,3 +90,30 @@ export function listenForRecentComments( | |||
| ) { | ||||
|   return listenForValues<Comment>(recentCommentsQuery, setComments) | ||||
| } | ||||
| 
 | ||||
| const getCommentsQuery = (startTime: number, endTime: number) => | ||||
|   query( | ||||
|     collectionGroup(db, 'comments'), | ||||
|     where('createdTime', '>=', startTime), | ||||
|     where('createdTime', '<', endTime), | ||||
|     orderBy('createdTime', 'asc') | ||||
|   ) | ||||
| 
 | ||||
| export async function getDailyComments( | ||||
|   startTime: number, | ||||
|   numberOfDays: number | ||||
| ) { | ||||
|   const query = getCommentsQuery( | ||||
|     startTime, | ||||
|     startTime + DAY_IN_MS * numberOfDays | ||||
|   ) | ||||
|   const comments = await getValues<Comment>(query) | ||||
| 
 | ||||
|   const commentsByDay = _.range(0, numberOfDays).map(() => [] as Comment[]) | ||||
|   for (const comment of comments) { | ||||
|     const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_IN_MS) | ||||
|     commentsByDay[dayIndex].push(comment) | ||||
|   } | ||||
| 
 | ||||
|   return commentsByDay | ||||
| } | ||||
|  |  | |||
|  | @ -209,3 +209,32 @@ export async function getClosingSoonContracts() { | |||
|     (contract) => contract.closeTime | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const getContractsQuery = (startTime: number, endTime: number) => | ||||
|   query( | ||||
|     collection(db, 'contracts'), | ||||
|     where('createdTime', '>=', startTime), | ||||
|     where('createdTime', '<', endTime), | ||||
|     orderBy('createdTime', 'asc') | ||||
|   ) | ||||
| 
 | ||||
| const DAY_IN_MS = 24 * 60 * 60 * 1000 | ||||
| 
 | ||||
| export async function getDailyContracts( | ||||
|   startTime: number, | ||||
|   numberOfDays: number | ||||
| ) { | ||||
|   const query = getContractsQuery( | ||||
|     startTime, | ||||
|     startTime + DAY_IN_MS * numberOfDays | ||||
|   ) | ||||
|   const contracts = await getValues<Contract>(query) | ||||
| 
 | ||||
|   const contractsByDay = _.range(0, numberOfDays).map(() => [] as Contract[]) | ||||
|   for (const contract of contracts) { | ||||
|     const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_IN_MS) | ||||
|     contractsByDay[dayIndex].push(contract) | ||||
|   } | ||||
| 
 | ||||
|   return contractsByDay | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| import { db } from './init' | ||||
| import { | ||||
|   doc, | ||||
|   getDoc, | ||||
|   getDocs, | ||||
|   onSnapshot, | ||||
|  | @ -22,7 +21,9 @@ export function listenForValue<T>( | |||
|   docRef: DocumentReference, | ||||
|   setValue: (value: T | null) => void | ||||
| ) { | ||||
|   return onSnapshot(docRef, (snapshot) => { | ||||
|   // Exclude cached snapshots so we only trigger on fresh data.
 | ||||
|   // includeMetadataChanges ensures listener is called even when server data is the same as cached data.
 | ||||
|   return onSnapshot(docRef, { includeMetadataChanges: true }, (snapshot) => { | ||||
|     if (snapshot.metadata.fromCache) return | ||||
| 
 | ||||
|     const value = snapshot.exists() ? (snapshot.data() as T) : null | ||||
|  | @ -34,7 +35,9 @@ export function listenForValues<T>( | |||
|   query: Query, | ||||
|   setValues: (values: T[]) => void | ||||
| ) { | ||||
|   return onSnapshot(query, (snapshot) => { | ||||
|   // Exclude cached snapshots so we only trigger on fresh data.
 | ||||
|   // includeMetadataChanges ensures listener is called even when server data is the same as cached data.
 | ||||
|   return onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => { | ||||
|     if (snapshot.metadata.fromCache) return | ||||
| 
 | ||||
|     const values = snapshot.docs.map((doc) => doc.data() as T) | ||||
|  |  | |||
|  | @ -127,7 +127,7 @@ export default function ContractPage(props: { | |||
|       )} | ||||
| 
 | ||||
|       <Col className="w-full justify-between md:flex-row"> | ||||
|         <div className="flex-[3] rounded border-0 border-gray-100 bg-white px-2 py-6 md:px-6 md:py-8"> | ||||
|         <div className="flex-1 rounded border-0 border-gray-100 bg-white px-2 py-6 md:px-6 md:py-8"> | ||||
|           <ContractOverview | ||||
|             contract={contract} | ||||
|             bets={bets ?? []} | ||||
|  | @ -154,9 +154,9 @@ export default function ContractPage(props: { | |||
|           <> | ||||
|             <div className="md:ml-6" /> | ||||
| 
 | ||||
|             <Col className="flex-1"> | ||||
|             <Col className="flex-shrink-0 md:w-[310px]"> | ||||
|               {allowTrade && ( | ||||
|                 <BetPanel className="hidden lg:inline" contract={contract} /> | ||||
|                 <BetPanel className="hidden lg:flex" contract={contract} /> | ||||
|               )} | ||||
|               {allowResolve && ( | ||||
|                 <ResolutionPanel creator={user} contract={contract} /> | ||||
|  |  | |||
|  | @ -118,6 +118,13 @@ function Contents() { | |||
|           </p> | ||||
|         </li> | ||||
|       </ol> | ||||
|       <p> | ||||
|         More questions? Check out{' '} | ||||
|         <a href="https://outsidetheasylum.blog/manifold-markets-faq/"> | ||||
|           this community-driven FAQ | ||||
|         </a> | ||||
|         ! | ||||
|       </p> | ||||
|       <h3 id="can-prediction-markets-work-without-real-money-"> | ||||
|         Can prediction markets work without real money? | ||||
|       </h3> | ||||
|  | @ -148,6 +155,40 @@ function Contents() { | |||
|         </a> | ||||
|         . | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="why-is-this-important-">Why is this important?</h3> | ||||
|       <p> | ||||
|         Prediction markets aggregate and reveal crucial information that would | ||||
|         not otherwise be known. They are a bottom-up mechanism that can | ||||
|         influence everything from politics, economics, and business, to | ||||
|         scientific research and education. | ||||
|       </p> | ||||
|       <p> | ||||
|         Prediction markets can predict{' '} | ||||
|         <a href="https://www.pnas.org/content/112/50/15343"> | ||||
|           which research papers will replicate | ||||
|         </a> | ||||
|         ; which drug is the most effective; which policy would generate the most | ||||
|         tax revenue; which charity will be underfunded; or which startup idea is | ||||
|         the most promising. By surfacing and quantifying our collective | ||||
|         knowledge, we as a society become wiser. | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="how-does-betting-work">How does betting work?</h3> | ||||
|       <ul> | ||||
|         <li>Markets are structured around a question with a binary outcome.</li> | ||||
|         <li> | ||||
|           Traders can place a bet on either YES or NO. The trader receives some | ||||
|           shares of the betting pool. The number of shares depends on the | ||||
|           current probability. | ||||
|         </li> | ||||
|         <li> | ||||
|           When the market is resolved, the traders who bet on the correct | ||||
|           outcome are paid out of the final pool in proportion to the number of | ||||
|           shares they own. | ||||
|         </li> | ||||
|       </ul> | ||||
| 
 | ||||
|       <h3 id="how-are-markets-resolved-">How are markets resolved?</h3> | ||||
|       <p> | ||||
|         The creator of the prediction market decides the outcome and earns{' '} | ||||
|  | @ -166,29 +207,9 @@ function Contents() { | |||
|         or even personal. (E.g. "Will I enjoy participating in the | ||||
|         Metaverse in 2023?") | ||||
|       </p> | ||||
|       <h3 id="why-is-this-important-">Why is this important?</h3> | ||||
|       <p> | ||||
|         Prediction markets aggregate and reveal crucial information that would | ||||
|         not otherwise be known. They are a bottom-up mechanism that can | ||||
|         influence everything from politics, economics, and business, to | ||||
|         scientific research and education. | ||||
|       </p> | ||||
|       <p> | ||||
|         Prediction markets can predict{' '} | ||||
|         <a href="https://www.pnas.org/content/112/50/15343"> | ||||
|           which research papers will replicate | ||||
|         </a> | ||||
|         ; which drug is the most effective; which policy would generate the most | ||||
|         tax revenue; which charity will be underfunded; or, which startup idea | ||||
|         is the most promising. | ||||
|       </p> | ||||
|       <p> | ||||
|         By surfacing and quantifying our collective knowledge, we as a society | ||||
|         become wiser. | ||||
|       </p> | ||||
|       <h3 id="how-is-this-different-from-metaculus-or-hypermind-"> | ||||
|       {/* <h3 id="how-is-this-different-from-metaculus-or-hypermind-"> | ||||
|         How is this different from Metaculus or Hypermind? | ||||
|       </h3> | ||||
|       </h3> */} | ||||
|       {/* <p> | ||||
|         We believe that in order to get the best results, you have to have skin | ||||
|         in the game. We require that people use real money to buy the currency | ||||
|  | @ -199,28 +220,13 @@ function Contents() { | |||
|         carefully and can't rig the outcome by creating multiple accounts. | ||||
|         The result is more accurate predictions. | ||||
|       </p> */} | ||||
|       <p> | ||||
|       {/* <p> | ||||
|         Manifold Markets is focused on accessibility and allowing anyone to | ||||
|         quickly create and judge a prediction market. When we all have the power | ||||
|         to create and share prediction markets in seconds and apply our own | ||||
|         judgment on the outcome, it leads to a qualitative shift in the number, | ||||
|         variety, and usefulness of prediction markets. | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="how-does-betting-work">How does betting work?</h3> | ||||
|       <ul> | ||||
|         <li>Markets are structured around a question with a binary outcome.</li> | ||||
|         <li> | ||||
|           Traders can place a bet on either YES or NO. The trader receives some | ||||
|           shares of the betting pool. The number of shares depends on the | ||||
|           current probability. | ||||
|         </li> | ||||
|         <li> | ||||
|           When the market is resolved, the traders who bet on the correct | ||||
|           outcome are paid out of the final pool in proportion to the number of | ||||
|           shares they own. | ||||
|         </li> | ||||
|       </ul> | ||||
|       </p> */} | ||||
| 
 | ||||
|       <h3 id="type-of-market-maker">What kind of betting system do you use?</h3> | ||||
|       <p> | ||||
|  | @ -249,6 +255,20 @@ function Contents() { | |||
|         to find out more! | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="private-markets">Can I create private markets?</h3> | ||||
|       <p> | ||||
|         Soon! We're running a pilot version of Manifold for Teams - private | ||||
|         Manifold instances where you can discuss internal topics and predict on | ||||
|         outcomes for your organization. | ||||
|       </p> | ||||
|       <p> | ||||
|         If this sounds like something you’d want,{' '} | ||||
|         <a href="https://docs.google.com/forms/d/e/1FAIpQLSfM_rxRHemCjKE6KPiYXGyP2nBSInZNKn_wc7yS1-rvlLAVnA/viewform?usp=sf_link"> | ||||
|           join the waitlist here | ||||
|         </a> | ||||
|         ! | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="who-are-we-">Who are we?</h3> | ||||
|       <p>Manifold Markets is currently a team of three:</p> | ||||
|       <ul> | ||||
|  | @ -296,19 +316,24 @@ function Contents() { | |||
|       <h2 id="further-reading">Further Reading</h2> | ||||
| 
 | ||||
|       <ul> | ||||
|         <li> | ||||
|           <a href="https://outsidetheasylum.blog/manifold-markets-faq/"> | ||||
|             An in-depth, unofficial FAQ by Isaac King | ||||
|           </a> | ||||
|         </li> | ||||
|         <li> | ||||
|           <a href="https://manifoldmarkets.notion.site/Technical-Guide-to-Manifold-Markets-b9b48a09ea1f45b88d991231171730c5"> | ||||
|             Technical Guide to Manifold Markets | ||||
|             How Manifold's market maker works | ||||
|           </a> | ||||
|         </li> | ||||
|         <li> | ||||
|           <a href="https://astralcodexten.substack.com/p/play-money-and-reputation-systems"> | ||||
|             Scott Alexander on play-money prediction markets | ||||
|           </a> | ||||
|         </li> | ||||
|         <li> | ||||
|           <a href="https://sideways-view.com/2019/10/27/prediction-markets-for-internet-points/"> | ||||
|             Paul Christiano: Prediction markets for internet points | ||||
|           </a> | ||||
|         </li> | ||||
|         <li> | ||||
|           <a href="https://thezvi.wordpress.com/2021/12/02/covid-prediction-markets-at-polymarket/"> | ||||
|             Zvi Mowshowitz on resolving prediction markets | ||||
|             Paul Christiano on prediction markets for internet points | ||||
|           </a> | ||||
|         </li> | ||||
|       </ul> | ||||
|  |  | |||
|  | @ -1,17 +1,121 @@ | |||
| import dayjs from 'dayjs' | ||||
| import _ from 'lodash' | ||||
| import { DailyCountChart } from '../components/analytics/charts' | ||||
| import { Col } from '../components/layout/col' | ||||
| import { Spacer } from '../components/layout/spacer' | ||||
| import { Page } from '../components/page' | ||||
| import { Title } from '../components/title' | ||||
| import { getDailyBets } from '../lib/firebase/bets' | ||||
| import { getDailyComments } from '../lib/firebase/comments' | ||||
| import { getDailyContracts } from '../lib/firebase/contracts' | ||||
| 
 | ||||
| export default function Analytics() { | ||||
|   // Edit dashboard at https://datastudio.google.com/u/0/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3/edit
 | ||||
| export async function getStaticProps() { | ||||
|   const numberOfDays = 80 | ||||
|   const today = dayjs(dayjs().format('YYYY-MM-DD')) | ||||
|   const startDate = today.subtract(numberOfDays, 'day') | ||||
| 
 | ||||
|   const [dailyBets, dailyContracts, dailyComments] = await Promise.all([ | ||||
|     getDailyBets(startDate.valueOf(), numberOfDays), | ||||
|     getDailyContracts(startDate.valueOf(), numberOfDays), | ||||
|     getDailyComments(startDate.valueOf(), numberOfDays), | ||||
|   ]) | ||||
| 
 | ||||
|   const dailyBetCounts = dailyBets.map((bets) => bets.length) | ||||
|   const dailyContractCounts = dailyContracts.map( | ||||
|     (contracts) => contracts.length | ||||
|   ) | ||||
|   const dailyCommentCounts = dailyComments.map((comments) => comments.length) | ||||
| 
 | ||||
|   const dailyActiveUsers = _.zip(dailyContracts, dailyBets, dailyComments).map( | ||||
|     ([contracts, bets, comments]) => { | ||||
|       const creatorIds = (contracts ?? []).map((c) => c.creatorId) | ||||
|       const betUserIds = (bets ?? []).map((bet) => bet.userId) | ||||
|       const commentUserIds = (comments ?? []).map((comment) => comment.userId) | ||||
|       return _.uniq([...creatorIds, ...betUserIds, commentUserIds]).length | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   return { | ||||
|     props: { | ||||
|       startDate: startDate.valueOf(), | ||||
|       dailyActiveUsers, | ||||
|       dailyBetCounts, | ||||
|       dailyContractCounts, | ||||
|       dailyCommentCounts, | ||||
|     }, | ||||
|     revalidate: 12 * 60 * 60, // regenerate after half a day
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default function Analytics(props: { | ||||
|   startDate: number | ||||
|   dailyActiveUsers: number[] | ||||
|   dailyBetCounts: number[] | ||||
|   dailyContractCounts: number[] | ||||
|   dailyCommentCounts: number[] | ||||
| }) { | ||||
|   return ( | ||||
|     <Page> | ||||
|       <iframe | ||||
|         className="w-full" | ||||
|         height={2200} | ||||
|         src="https://datastudio.google.com/embed/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3" | ||||
|         frameBorder="0" | ||||
|         style={{ border: 0 }} | ||||
|         allowFullScreen | ||||
|       ></iframe> | ||||
|       <CustomAnalytics {...props} /> | ||||
|       <Spacer h={8} /> | ||||
|       <FirebaseAnalytics /> | ||||
|     </Page> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function CustomAnalytics(props: { | ||||
|   startDate: number | ||||
|   dailyActiveUsers: number[] | ||||
|   dailyBetCounts: number[] | ||||
|   dailyContractCounts: number[] | ||||
|   dailyCommentCounts: number[] | ||||
| }) { | ||||
|   const { | ||||
|     startDate, | ||||
|     dailyActiveUsers, | ||||
|     dailyBetCounts, | ||||
|     dailyContractCounts, | ||||
|     dailyCommentCounts, | ||||
|   } = props | ||||
|   return ( | ||||
|     <Col> | ||||
|       <Title text="Active users" /> | ||||
|       <DailyCountChart dailyCounts={dailyActiveUsers} startDate={startDate} /> | ||||
| 
 | ||||
|       <Title text="Bets count" /> | ||||
|       <DailyCountChart | ||||
|         dailyCounts={dailyBetCounts} | ||||
|         startDate={startDate} | ||||
|         small | ||||
|       /> | ||||
| 
 | ||||
|       <Title text="Markets count" /> | ||||
|       <DailyCountChart | ||||
|         dailyCounts={dailyContractCounts} | ||||
|         startDate={startDate} | ||||
|         small | ||||
|       /> | ||||
| 
 | ||||
|       <Title text="Comments count" /> | ||||
|       <DailyCountChart | ||||
|         dailyCounts={dailyCommentCounts} | ||||
|         startDate={startDate} | ||||
|         small | ||||
|       /> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function FirebaseAnalytics() { | ||||
|   // Edit dashboard at https://datastudio.google.com/u/0/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3/edit
 | ||||
|   return ( | ||||
|     <iframe | ||||
|       className="w-full" | ||||
|       height={2200} | ||||
|       src="https://datastudio.google.com/embed/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3" | ||||
|       frameBorder="0" | ||||
|       style={{ border: 0 }} | ||||
|       allowFullScreen | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -137,7 +137,7 @@ export function NewContract(props: { question: string; tag?: string }) { | |||
|         <span className="mb-1">Answer type</span> | ||||
|       </label> | ||||
|       <Row className="form-control gap-2"> | ||||
|         <label className="cursor-pointer label gap-2"> | ||||
|         <label className="label cursor-pointer gap-2"> | ||||
|           <input | ||||
|             className="radio" | ||||
|             type="radio" | ||||
|  | @ -149,7 +149,7 @@ export function NewContract(props: { question: string; tag?: string }) { | |||
|           <span className="label-text">Yes / No</span> | ||||
|         </label> | ||||
| 
 | ||||
|         <label className="cursor-pointer label gap-2"> | ||||
|         <label className="label cursor-pointer gap-2"> | ||||
|           <input | ||||
|             className="radio" | ||||
|             type="radio" | ||||
|  | @ -248,6 +248,7 @@ export function NewContract(props: { question: string; tag?: string }) { | |||
|           error={anteError} | ||||
|           setError={setAnteError} | ||||
|           disabled={isSubmitting} | ||||
|           contractIdForLoan={undefined} | ||||
|         /> | ||||
|       </div> | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,19 +5,17 @@ import { Fold } from '../../../../common/fold' | |||
| import { Comment } from '../../../../common/comment' | ||||
| import { Page } from '../../../components/page' | ||||
| import { Title } from '../../../components/title' | ||||
| import { | ||||
|   Bet, | ||||
|   getRecentContractBets, | ||||
|   listAllBets, | ||||
| } from '../../../lib/firebase/bets' | ||||
| import { listAllComments } from '../../../lib/firebase/comments' | ||||
| import { Bet, listAllBets } from '../../../lib/firebase/bets' | ||||
| import { Contract } from '../../../lib/firebase/contracts' | ||||
| import { | ||||
|   foldPath, | ||||
|   getFoldBySlug, | ||||
|   getFoldContracts, | ||||
| } from '../../../lib/firebase/folds' | ||||
| import { ActivityFeed, findActiveContracts } from '../../activity' | ||||
| import { | ||||
|   ActivityFeed, | ||||
|   findActiveContracts, | ||||
| } from '../../../components/activity-feed' | ||||
| import { TagsList } from '../../../components/tags-list' | ||||
| import { Row } from '../../../components/layout/row' | ||||
| import { UserLink } from '../../../components/user-page' | ||||
|  | @ -42,6 +40,9 @@ import { useTaggedContracts } from '../../../hooks/use-contracts' | |||
| import { Linkify } from '../../../components/linkify' | ||||
| import { usePropz } from '../../../hooks/use-propz' | ||||
| import { filterDefined } from '../../../../common/util/array' | ||||
| import { useRecentBets } from '../../../hooks/use-bets' | ||||
| import { useRecentComments } from '../../../hooks/use-comments' | ||||
| import { LoadingIndicator } from '../../../components/loading-indicator' | ||||
| 
 | ||||
| export async function getStaticPropz(props: { params: { slugs: string[] } }) { | ||||
|   const { slugs } = props.params | ||||
|  | @ -51,42 +52,17 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { | |||
| 
 | ||||
|   const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : [] | ||||
| 
 | ||||
|   const betsPromise = Promise.all( | ||||
|   const bets = await Promise.all( | ||||
|     contracts.map((contract) => listAllBets(contract.id)) | ||||
|   ) | ||||
| 
 | ||||
|   const [contractComments, contractRecentBets] = await Promise.all([ | ||||
|     Promise.all( | ||||
|       contracts.map((contract) => listAllComments(contract.id).catch((_) => [])) | ||||
|     ), | ||||
|     Promise.all( | ||||
|       contracts.map((contract) => | ||||
|         getRecentContractBets(contract.id).catch((_) => []) | ||||
|       ) | ||||
|     ), | ||||
|   ]) | ||||
| 
 | ||||
|   let activeContracts = findActiveContracts( | ||||
|     contracts, | ||||
|     _.flatten(contractComments), | ||||
|     _.flatten(contractRecentBets) | ||||
|   ) | ||||
|   let activeContracts = findActiveContracts(contracts, [], _.flatten(bets)) | ||||
|   const [resolved, unresolved] = _.partition( | ||||
|     activeContracts, | ||||
|     ({ isResolved }) => isResolved | ||||
|   ) | ||||
|   activeContracts = [...unresolved, ...resolved] | ||||
| 
 | ||||
|   const activeContractBets = await Promise.all( | ||||
|     activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) | ||||
|   ) | ||||
|   const activeContractComments = activeContracts.map( | ||||
|     (contract) => | ||||
|       contractComments[contracts.findIndex((c) => c.id === contract.id)] | ||||
|   ) | ||||
| 
 | ||||
|   const bets = await betsPromise | ||||
| 
 | ||||
|   const creatorScores = scoreCreators(contracts, bets) | ||||
|   const traderScores = scoreTraders(contracts, bets) | ||||
|   const [topCreators, topTraders] = await Promise.all([ | ||||
|  | @ -102,8 +78,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { | |||
|       curator, | ||||
|       contracts, | ||||
|       activeContracts, | ||||
|       activeContractBets, | ||||
|       activeContractComments, | ||||
|       traderScores, | ||||
|       topTraders, | ||||
|       creatorScores, | ||||
|  | @ -156,15 +130,8 @@ export default function FoldPage(props: { | |||
|     creatorScores: {}, | ||||
|     topCreators: [], | ||||
|   } | ||||
|   const { | ||||
|     curator, | ||||
|     activeContractBets, | ||||
|     activeContractComments, | ||||
|     traderScores, | ||||
|     topTraders, | ||||
|     creatorScores, | ||||
|     topCreators, | ||||
|   } = props | ||||
|   const { curator, traderScores, topTraders, creatorScores, topCreators } = | ||||
|     props | ||||
| 
 | ||||
|   const router = useRouter() | ||||
|   const { slugs } = router.query as { slugs: string[] } | ||||
|  | @ -190,6 +157,9 @@ export default function FoldPage(props: { | |||
|     props.activeContracts.map((contract) => contractsMap[contract.id]) | ||||
|   ) | ||||
| 
 | ||||
|   const recentBets = useRecentBets() | ||||
|   const recentComments = useRecentComments() | ||||
| 
 | ||||
|   if (fold === null || !foldSubpages.includes(page) || slugs[2]) { | ||||
|     return <Custom404 /> | ||||
|   } | ||||
|  | @ -272,19 +242,24 @@ export default function FoldPage(props: { | |||
|               /> | ||||
|             )} | ||||
|             {page === 'activity' ? ( | ||||
|               <> | ||||
|                 <ActivityFeed | ||||
|                   contracts={activeContracts} | ||||
|                   contractBets={activeContractBets} | ||||
|                   contractComments={activeContractComments} | ||||
|                 /> | ||||
|                 {activeContracts.length === 0 && ( | ||||
|                   <div className="mx-2 mt-4 text-gray-500 lg:mx-0"> | ||||
|                     No activity from matching markets.{' '} | ||||
|                     {isCurator && 'Try editing to add more tags!'} | ||||
|                   </div> | ||||
|                 )} | ||||
|               </> | ||||
|               recentBets && recentComments ? ( | ||||
|                 <> | ||||
|                   <ActivityFeed | ||||
|                     contracts={activeContracts} | ||||
|                     recentBets={recentBets ?? []} | ||||
|                     recentComments={recentComments ?? []} | ||||
|                     loadBetAndCommentHistory | ||||
|                   /> | ||||
|                   {activeContracts.length === 0 && ( | ||||
|                     <div className="mx-2 mt-4 text-gray-500 lg:mx-0"> | ||||
|                       No activity from matching markets.{' '} | ||||
|                       {isCurator && 'Try editing to add more tags!'} | ||||
|                     </div> | ||||
|                   )} | ||||
|                 </> | ||||
|               ) : ( | ||||
|                 <LoadingIndicator className="mt-4" /> | ||||
|               ) | ||||
|             ) : ( | ||||
|               <SearchableGrid | ||||
|                 contracts={contracts} | ||||
|  |  | |||
|  | @ -6,9 +6,8 @@ import _ from 'lodash' | |||
| 
 | ||||
| import { Contract } from '../lib/firebase/contracts' | ||||
| import { Page } from '../components/page' | ||||
| import { ActivityFeed, SummaryActivityFeed } from './activity' | ||||
| import { ActivityFeed, SummaryActivityFeed } from '../components/activity-feed' | ||||
| import { Comment } from '../lib/firebase/comments' | ||||
| import { Bet } from '../lib/firebase/bets' | ||||
| import FeedCreate from '../components/feed-create' | ||||
| import { Spacer } from '../components/layout/spacer' | ||||
| import { Col } from '../components/layout/col' | ||||
|  | @ -23,10 +22,11 @@ import { | |||
|   useFilterYourContracts, | ||||
|   useFindActiveContracts, | ||||
| } from '../hooks/use-find-active-contracts' | ||||
| import { useGetRecentBets } from '../hooks/use-bets' | ||||
| import { usePropz } from '../hooks/use-propz' | ||||
| import { useActiveContracts } from '../hooks/use-contracts' | ||||
| import { IS_PRIVATE_MANIFOLD } from '../lib/firebase/init' | ||||
| import { useGetRecentBets, useRecentBets } from '../hooks/use-bets' | ||||
| import { useActiveContracts } from '../hooks/use-contracts' | ||||
| import { useRecentComments } from '../hooks/use-comments' | ||||
| 
 | ||||
| export async function getStaticPropz() { | ||||
|   const contractInfo = await getAllContractInfo() | ||||
|  | @ -40,7 +40,6 @@ export async function getStaticPropz() { | |||
| const Home = (props: { | ||||
|   contracts: Contract[] | ||||
|   folds: Fold[] | ||||
|   recentBets: Bet[] | ||||
|   recentComments: Comment[] | ||||
| }) => { | ||||
|   props = usePropz(getStaticPropz) ?? { | ||||
|  | @ -48,7 +47,7 @@ const Home = (props: { | |||
|     folds: [], | ||||
|     recentComments: [], | ||||
|   } | ||||
|   const { folds, recentComments } = props | ||||
|   const { folds } = props | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   const contracts = useActiveContracts() ?? props.contracts | ||||
|  | @ -58,13 +57,15 @@ const Home = (props: { | |||
|     contracts | ||||
|   ) | ||||
| 
 | ||||
|   const recentBets = useGetRecentBets() | ||||
|   const { activeContracts, activeBets, activeComments } = | ||||
|     useFindActiveContracts({ | ||||
|       contracts: yourContracts, | ||||
|       recentBets: recentBets ?? [], | ||||
|       recentComments, | ||||
|     }) | ||||
|   const initialRecentBets = useGetRecentBets() | ||||
|   const recentBets = useRecentBets() ?? initialRecentBets | ||||
|   const recentComments = useRecentComments() ?? props.recentComments | ||||
| 
 | ||||
|   const { activeContracts } = useFindActiveContracts({ | ||||
|     contracts: yourContracts, | ||||
|     recentBets: initialRecentBets ?? [], | ||||
|     recentComments: props.recentComments, | ||||
|   }) | ||||
| 
 | ||||
|   const exploreContracts = useExploreContracts() | ||||
| 
 | ||||
|  | @ -78,7 +79,7 @@ const Home = (props: { | |||
|   return ( | ||||
|     <Page assertUser="signed-in"> | ||||
|       <Col className="items-center"> | ||||
|         <Col className="w-full max-w-3xl"> | ||||
|         <Col className="w-full max-w-[700px]"> | ||||
|           <FeedCreate user={user ?? undefined} /> | ||||
|           <Spacer h={6} /> | ||||
| 
 | ||||
|  | @ -93,7 +94,7 @@ const Home = (props: { | |||
| 
 | ||||
|           <Spacer h={5} /> | ||||
| 
 | ||||
|           <Col className="mx-3 mb-3 gap-2 text-sm text-gray-800 sm:flex-row"> | ||||
|           <Col className="mb-3 gap-2 text-sm text-gray-800 sm:flex-row"> | ||||
|             <Row className="gap-2"> | ||||
|               <div className="tabs"> | ||||
|                 <div | ||||
|  | @ -124,8 +125,8 @@ const Home = (props: { | |||
|             (recentBets ? ( | ||||
|               <ActivityFeed | ||||
|                 contracts={activeContracts} | ||||
|                 contractBets={activeBets} | ||||
|                 contractComments={activeComments} | ||||
|                 recentBets={recentBets} | ||||
|                 recentComments={recentComments} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <LoadingIndicator className="mt-4" /> | ||||
|  |  | |||
|  | @ -245,6 +245,7 @@ ${TEST_VALUE} | |||
|             error={anteError} | ||||
|             setError={setAnteError} | ||||
|             disabled={isSubmitting} | ||||
|             contractIdForLoan={undefined} | ||||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user