Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-10-02 17:53:05 -07:00 committed by GitHub
commit 9328e9a238
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
199 changed files with 10985 additions and 3997 deletions

View File

@ -21,6 +21,25 @@ const computeInvestmentValue = (
}) })
} }
export const computeInvestmentValueCustomProb = (
bets: Bet[],
contract: Contract,
p: number
) => {
return sumBy(bets, (bet) => {
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const { outcome, shares } = bet
const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime

View File

@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userName: string userName: string
userUsername: string userUsername: string
userAvatarUrl?: string userAvatarUrl?: string
bountiesAwarded?: number
} & T } & T
export type OnContract = { export type OnContract = {

View File

@ -57,10 +57,12 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number popularityScore?: number
dailyScore?: number
followerCount?: number followerCount?: number
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[] likedByUserIds?: string[]
likedByUserCount?: number likedByUserCount?: number
openCommentBounties?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT =
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment // this is Phil's deployment
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
sprigEnvironmentId: 'Tu7kRZPm7daP',
} }

View File

@ -3,6 +3,7 @@ export type EnvConfig = {
firebaseConfig: FirebaseConfig firebaseConfig: FirebaseConfig
amplitudeApiKey?: string amplitudeApiKey?: string
twitchBotEndpoint?: string twitchBotEndpoint?: string
sprigEnvironmentId?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and // IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -40,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
@ -56,6 +58,7 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = { export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets', domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
sprigEnvironmentId: 'sQcrq9TDqkib',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',

View File

@ -10,6 +10,7 @@ export type Group = {
totalContracts: number totalContracts: number
totalMembers: number totalMembers: number
aboutPostId?: string aboutPostId?: string
postIds: string[]
chatDisabled?: boolean chatDisabled?: boolean
mostRecentContractAddedTime?: number mostRecentContractAddedTime?: number
cachedLeaderboard?: { cachedLeaderboard?: {
@ -22,6 +23,7 @@ export type Group = {
score: number score: number
}[] }[]
} }
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75

View File

@ -5,4 +5,4 @@ export type Like = {
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 5 export const LIKE_TIP_AMOUNT = 10

View File

@ -116,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching", detailed: "Only answers by market creator on markets you're watching",
}, },
betting_streaks: { betting_streaks: {
simple: 'For predictions made over consecutive days', simple: `For prediction streaks`,
detailed: 'Bonuses for predictions made over consecutive days', detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
}, },
comments_by_followed_users_on_watched_markets: { comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow', simple: 'Only comments by users you follow',
@ -159,8 +159,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch', detailed: 'Large changes in probability on markets that you watch',
}, },
profit_loss_updates: { profit_loss_updates: {
simple: 'Weekly profit and loss updates', simple: 'Weekly portfolio updates',
detailed: 'Weekly profit and loss updates', detailed: 'Weekly portfolio updates',
}, },
referral_bonuses: { referral_bonuses: {
simple: 'For referring new users', simple: 'For referring new users',

View File

@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
const profit = winnings - amount const profit = winnings - amount
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit) const payout = amount + (1 - DPM_FEES) * profit
return { userId, profit, payout } return { userId, profit, payout }
}) })

View File

@ -9,4 +9,11 @@ export type Post = {
slug: string slug: string
} }
export type DateDoc = Post & {
bounty: number
birthday: number
type: 'date-doc'
contractSlug: string
}
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480

View File

@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus | UniqueBettorBonus
| BettingStreakBonus | BettingStreakBonus
| CancelUniqueBettorBonus | CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS' | 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS' | 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
} }
} }
type CommentBountyDeposit = {
fromType: 'USER'
toType: 'BANK'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
}
}
type CommentBountyWithdrawal = {
fromType: 'BANK'
toType: 'USER'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
commentId: string
}
}
type CommentBountyRefund = {
fromType: 'BANK'
toType: 'USER'
category: 'REFUND_COMMENT_BOUNTY'
data: {
contractId: string
}
}
export type DonationTxn = Txn & Donation export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink export type ManalinkTxn = Txn & Manalink
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal

View File

@ -57,6 +57,7 @@ export type PrivateUser = {
email?: string email?: string
weeklyTrendingEmailSent?: boolean weeklyTrendingEmailSent?: boolean
weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string

View File

@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
}) })
export function formatMoney(amount: number) { export function formatMoney(amount: number) {
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case const newAmount =
// handle -0 case
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
Math.floor(amount + 0.00000000001 * Math.sign(amount))
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }

View File

@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) { export function getUrl(text: string) {
@ -103,6 +104,7 @@ export const exhibitExts = [
Mention, Mention,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler,
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {

View File

@ -1,3 +1,6 @@
export const MINUTE_MS = 60 * 1000 export const MINUTE_MS = 60 * 1000
export const HOUR_MS = 60 * MINUTE_MS export const HOUR_MS = 60 * MINUTE_MS
export const DAY_MS = 24 * HOUR_MS export const DAY_MS = 24 * HOUR_MS
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms))

View File

@ -0,0 +1,116 @@
// adapted from @n8body/tiptap-spoiler
import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from '@tiptap/core'
import type { ElementType } from 'react'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
spoilerEditor: {
setSpoiler: () => ReturnType
toggleSpoiler: () => ReturnType
unsetSpoiler: () => ReturnType
}
}
}
export type SpoilerOptions = {
HTMLAttributes: Record<string, any>
spoilerOpenClass: string
spoilerCloseClass?: string
inputRegex: RegExp
pasteRegex: RegExp
as: ElementType
}
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
name: 'spoiler',
inline: true,
group: 'inline',
inclusive: false,
exitable: true,
content: 'inline*',
priority: 200, // higher priority than other formatting so they go inside
addOptions() {
return {
HTMLAttributes: { 'aria-label': 'spoiler' },
spoilerOpenClass: '',
spoilerCloseClass: undefined,
inputRegex: spoilerInputRegex,
pasteRegex: spoilerPasteRegex,
as: 'span',
editing: false,
}
},
addCommands() {
return {
setSpoiler:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleSpoiler:
() =>
({ commands }) =>
commands.toggleMark(this.name),
unsetSpoiler:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
addInputRules() {
return [
markInputRule({
find: this.options.inputRegex,
type: this.type,
}),
]
},
addPasteRules() {
return [
markPasteRule({
find: this.options.pasteRegex,
type: this.type,
}),
]
},
parseHTML() {
return [
{
tag: 'span',
getAttrs: (node) =>
(node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
},
]
},
renderHTML({ HTMLAttributes }) {
const elem = document.createElement(this.options.as as string)
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
})
).forEach(([attr, val]) => elem.setAttribute(attr, val))
elem.addEventListener('click', () => {
elem.setAttribute('class', this.options.spoilerOpenClass)
})
return elem
},
})

View File

@ -55,6 +55,7 @@ Returns the authenticated user.
Gets all groups, in no particular order. Gets all groups, in no particular order.
Parameters: Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can - `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned. join and groups they've already joined will be returned.
@ -81,7 +82,6 @@ Gets a group's markets by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL. Note: group is singular in the URL.
### `GET /v0/markets` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.
@ -158,13 +158,16 @@ Requires no authorization.
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
url: string url: string
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC
mechanism: string // dpm-2 or cpmm-1 mechanism: string // dpm-2 or cpmm-1
probability: number probability: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
volume: number volume: number
volume7Days: number volume7Days: number
@ -408,7 +411,7 @@ Requires no authorization.
type FullMarket = LiteMarket & { type FullMarket = LiteMarket & {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers?: Answer[] answers?: Answer[] // dpm-2 markets only
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds textDescription: string // string description without formatting, images, or embeds
} }
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
Parameters: Parameters:
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. - `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
- `question`: Required. The headline question for the market. - `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules for the market. - `description`: Required. A long description describing the rules for the market.
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
@ -569,6 +572,12 @@ For numeric markets, you must also provide:
- `min`: The minimum value that the market may resolve to. - `min`: The minimum value that the market may resolve to.
- `max`: The maximum value that the market may resolve to. - `max`: The maximum value that the market may resolve to.
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
- `initialValue`: An initial value for the market, between min and max, exclusive.
For multiple choice markets, you must also provide:
- `answers`: An array of strings, each of which will be a valid answer for the market.
Example request: Example request:
@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}' "initialProb":25}'
``` ```
### `POST /v0/market/[marketId]/add-liquidity`
Adds a specified amount of liquidity into the market.
- `amount`: Required. The amount of liquidity to add, in M$.
### `POST /v0/market/[marketId]/close`
Closes a market on behalf of the authorized user.
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
### `POST /v0/market/[marketId]/resolve` ### `POST /v0/market/[marketId]/resolve`
Resolves a market on behalf of the authorized user. Resolves a market on behalf of the authorized user.
@ -593,15 +614,18 @@ For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution. - `probabilityInt`: Optional. The probability to use for `MKT` resolution.
For free response markets: For free response or multiple choice markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. - `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100.
For numeric markets: For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to. - `value`: The value that the market may resolves to.
- `probabilityInt`: Required if `value` is present. Should be equal to
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
- Otherwise: `(value - min) / (max - min)`
Example request: Example request:
@ -745,6 +769,7 @@ Requires no authorization.
## Changelog ## Changelog
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
- 2022-07-15: Add user by username and user by ID APIs - 2022-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint - 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints - 2022-06-05: Add new authorized write endpoints

View File

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

View File

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

View File

@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {

View File

@ -40,7 +40,6 @@
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2", "node-fetch": "2",
"react-masonry-css": "1.0.16",
"stripe": "8.194.0", "stripe": "8.194.0",
"zod": "3.17.2" "zod": "3.17.2"
}, },
@ -48,7 +47,8 @@
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3" "firebase-functions-test": "0.3.3",
"puppeteer": "18.0.5"
}, },
"private": true "private": true
} }

View File

@ -14,7 +14,7 @@ import {
export { APIError } from '../../common/api' export { APIError } from '../../common/api'
type Output = Record<string, unknown> type Output = Record<string, unknown>
type AuthedUser = { export type AuthedUser = {
uid: string uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
} }

View File

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

View File

@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getValues } from './utils' import { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string().max(MAX_ANSWER_LENGTH), contractId: z.string().max(MAX_ANSWER_LENGTH),
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer return answer
}) })
await addUserToContractFollowers(contractId, auth.uid)
return answer return answer
}) })

View File

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

View File

@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser, getContract, isProd } from './utils' import { chargeUser, getContract, isProd } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api' import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2), answers: z.string().trim().min(1).array().min(2),
}) })
export const createmarket = newEndpoint({}, async (req, auth) => { export const createmarket = newEndpoint({}, (req, auth) => {
return createMarketHelper(req.body, auth)
})
export async function createMarketHelper(body: any, auth: AuthedUser) {
const { const {
question, question,
description, description,
@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
outcomeType, outcomeType,
groupId, groupId,
visibility = 'public', visibility = 'public',
} = validate(bodySchema, req.body) } = validate(bodySchema, body)
let min, max, initialProb, isLogScale, answers let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
;({ min, max, initialValue, isLogScale } = validate( ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max) if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.') throw new APIError(400, 'Invalid range.')
@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') { if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body)) ;({ answers } = validate(multipleChoiceSchema, body))
} }
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
// convert string descriptions into JSONContent // convert string descriptions into JSONContent
const newDescription = const newDescription =
typeof description === 'string' !description || typeof description === 'string'
? { ? {
type: 'doc', type: 'doc',
content: [ content: [
{ {
type: 'paragraph', type: 'paragraph',
content: [{ type: 'text', text: description }], content: [{ type: 'text', text: description || ' ' }],
}, },
], ],
} }
: description ?? {} : description
const contract = getNewContract( const contract = getNewContract(
contractRef.id, contractRef.id,
@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
return contract return contract
}) }
const getSlug = async (question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)

View File

@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async (
) )
) )
} }
export const createBountyNotification = async (
fromUser: User,
toUserId: string,
amount: number,
idempotencyKey: string,
contract: Contract,
commentId?: string
) => {
const privateUser = await getPrivateUser(toUserId)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'tip_received'
)
if (!sendToBrowser) return
const slug = commentId
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: commentId ? commentId : contract.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
// maybe TODO: send email notification to comment creator
}

View File

@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { createMarketHelper } from './create-market'
import { DAY_MS } from '../../common/util/time'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() => const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -34,11 +37,21 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(),
// Date doc fields:
bounty: z.number().optional(),
birthday: z.number().optional(),
type: z.string().optional(),
question: z.string().optional(),
}) })
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body) const { title, content, groupId, question, ...otherProps } = validate(
postSchema,
req.body
)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -50,16 +63,50 @@ export const createpost = newEndpoint({}, async (req, auth) => {
const postRef = firestore.collection('posts').doc() const postRef = firestore.collection('posts').doc()
const post: Post = { // If this is a date doc, create a market for it.
let contractSlug
if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper(
{
question,
closeTime,
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
// Dating group!
groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
contractSlug = result.slug
}
const post: Post = removeUndefinedProps({
...otherProps,
id: postRef.id, id: postRef.id,
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
} contractSlug,
})
await postRef.create(post) await postRef.create(post)
if (groupId) {
const groupRef = firestore.collection('groups').doc(groupId)
const group = await groupRef.get()
if (group.exists) {
const groupData = group.data()
if (groupData) {
const postIds = groupData.postIds ?? []
postIds.push(postRef.id)
await groupRef.update({ postIds })
}
}
}
return { status: 'success', post } return { status: 'success', post }
}) })

View File

@ -0,0 +1,411 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Weekly Portfolio Update on Manifold</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family:"Readex Pro", Helvetica, sans-serif;
}
table { margin: 0 auto; }
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
th {color:#000000; font-size:17px;}
th, td {padding: 10px; }
td{ font-size: 17px}
th, td { vertical-align: center; text-align: left }
a { vertical-align: center; text-align: left}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
p.change{
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
}
p.prob{
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
}
a.question{
font-size: 18px;display: inline; vertical-align: middle;
}
td.question{
vertical-align: middle; padding-bottom: 15px; text-align: left;
}
td.probs{
text-align: right; padding-left: 10px; min-width: 115px
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto"
src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align: top; margin-bottom: 30px" width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
We ran the numbers and here's how you did this past week!
</span>
</p>
</div>
</td>
</tr>
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
<tr>
<tr>
<th style='font-size: 22px; text-align: center'>
Profit
</th>
</tr>
<tr>
<td style='padding-bottom: 30px; text-align: center'>
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
{{profit}}
</p>
</td>
</tr>
<td align="center"
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px; ">
<tbody>
<tr>
<th style='width: 170px'>
🔥 Prediction streak
</th>
<td>
{{prediction_streak}}
</td>
</tr>
<tr>
<th>
💸 Tips received
</th>
<td>
{{tips_received}}
</td>
</tr>
<tr>
<th>
📈 Markets traded
</th>
<td>
{{markets_traded}}
</td>
</tr>
<tr>
<th>
❓ Markets created
</th>
<td>
{{markets_created}}
</td>
</tr>
<tr>
<th style='width: 55px'>
🥳 Traders attracted
</th>
<td>
{{unique_bettors}}
</td>
</tr>
</tbody>
</table>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation" width="100%">
<tbody>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
">
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,510 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Weekly Portfolio Update on Manifold</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family:"Readex Pro", Helvetica, sans-serif;
}
table { margin: 0 auto; }
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
th {color:#000000; font-size:17px;}
th, td {padding: 10px; }
td{ font-size: 17px}
th, td { vertical-align: center; text-align: left }
a { vertical-align: center; text-align: left}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
p.change{
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
}
p.prob{
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
}
a.question{
font-size: 18px;display: inline; vertical-align: middle;
}
td.question{
vertical-align: middle; padding-bottom: 15px; text-align: left;
}
td.probs{
text-align: right; padding-left: 10px; min-width: 115px
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto"
src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align: top" width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
We ran the numbers and here's how you did this past week!
</span>
</p>
</div>
</td>
</tr>
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
<tr>
<tr>
<th style='font-size: 22px; text-align: center'>
Profit
</th>
</tr>
<tr>
<td style='padding-bottom: 30px; text-align: center'>
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
{{profit}}
</p>
</td>
</tr>
<td align="center"
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px; ">
<tbody>
<tr>
<th style='width: 170px'>
🔥 Prediction streak
</th>
<td>
{{prediction_streak}}
</td>
</tr>
<tr>
<th>
💸 Tips received
</th>
<td>
{{tips_received}}
</td>
</tr>
<tr>
<th>
📈 Markets traded
</th>
<td>
{{markets_traded}}
</td>
</tr>
<tr>
<th>
❓ Markets created
</th>
<td>
{{markets_created}}
</td>
</tr>
<tr>
<th style='width: 55px'>
🥳 Traders attracted
</th>
<td>
{{unique_bettors}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
And here's some recent changes in your investments:
</span>
</p>
</div>
</td>
<tr>
<td
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
<table role="presentation">
<tbody>
<tr>
<td class='question'>
<a class='question' href='{{question1Url}}'>
{{question1Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question1Prob}}
<!-- 9.9%-->
<p class='change' style='{{question1ChangeStyle}}'>
{{question1Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<td class='question'>
<a class='question' href='{{question2Url}}'>
{{question2Title}}
<!-- Will the US economy recover from the pandemic? blah blah blah-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question2Prob}}
<!-- 99.9%-->
<p class='change' style='{{question2ChangeStyle}}'>
{{question2Change}}
<!-- +7%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td class='question'>
<a class='question' href='{{question3Url}}'>
{{question3Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question3Prob}}
<!-- 99.9%-->
<p class='change' style='{{question3ChangeStyle}}'>
{{question3Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td class='question'>
<a class='question' href='{{question4Url}}'>
{{question4Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question4Prob}}
<!-- 99.9%-->
<p class='change' style='{{question4ChangeStyle}}'>
{{question4Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation" width="100%">
<tbody>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
">
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils' import { contractUrl, getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { import {
getNotificationDestinationsForUser, PerContractInvestmentsData,
notification_preference, OverallPerformanceData,
} from '../../common/user-notification-preferences' } from './weekly-portfolio-emails'
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
reason: notification_reason_types, reason: notification_reason_types,
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'onboarding_flow' as notification_preference privateUser,
}` 'onboarding_flow'
)
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'onboarding_flow' as notification_preference privateUser,
}` 'onboarding_flow'
)
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold Markets one week anniversary gift', 'Manifold Markets one week anniversary gift',
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ privateUser,
'onboarding_flow' as notification_preference 'onboarding_flow'
}` )
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ privateUser,
'thank_you_for_purchases' as notification_preference 'thank_you_for_purchases'
}` )
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
) )
return return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const { unsubscribeUrl } = getNotificationDestinationsForUser(
'trending_markets' as notification_preference privateUser,
}` 'trending_markets'
)
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async (
) )
} }
function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}
function imageSourceUrl(contract: Contract) { function imageSourceUrl(contract: Contract) {
return buildCardUrl(getOpenGraphProps(contract)) return buildCardUrl(getOpenGraphProps(contract))
} }
@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async (
} }
) )
} }
export const sendWeeklyPortfolioUpdateEmail = async (
user: User,
privateUser: PrivateUser,
investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'profit_loss_updates'
)
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
name: firstName,
unsubscribeUrl,
...overallPerformance,
}
investments.forEach((investment, i) => {
templateData[`question${i + 1}Title`] = investment.questionTitle
templateData[`question${i + 1}Url`] = investment.questionUrl
templateData[`question${i + 1}Prob`] = investment.questionProb
templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
})
await sendTemplateEmail(
privateUser.email,
// 'iansphilips@gmail.com',
`Here's your weekly portfolio update!`,
investments.length === 0
? 'portfolio-update-no-movers'
: 'portfolio-update',
templateData
)
}

View File

@ -27,9 +27,10 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
export * from './weekly-markets-emails' export * from './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag' export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-portfolio-emails'
// v2 // v2
export * from './health' export * from './health'
@ -50,6 +51,8 @@ export * from './resolve-market'
export * from './unsubscribe' export * from './unsubscribe'
export * from './stripe' export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market'
export * from './update-comment-bounty'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -63,9 +66,11 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
@ -88,9 +93,12 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity) const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe) const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook) const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
@ -115,11 +123,14 @@ export {
withdrawLiquidityFunction as withdrawliquidity, withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup, createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket, resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket,
unsubscribeFunction as unsubscribe, unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook, stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession, createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
} }

View File

@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
'contract', 'contract',
'closed', 'closed',
user, user,
'closed' + contract.id.slice(6, contract.id.length), contract.id + '-closed-at-' + contract.closeTime,
contract.closeTime?.toString() ?? new Date().toString(), contract.closeTime?.toString() ?? new Date().toString(),
{ contract } { contract }
) )

View File

@ -1,33 +1,48 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues, log } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Txn } from '../../common/txn'
import { partition, sortBy } from 'lodash'
import { runTxn, TxnData } from './transact'
import * as admin from 'firebase-admin'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
.onUpdate(async (change, context) => { .onUpdate(async (change, context) => {
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
if (
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
await handleUnusedCommentBountyRefunds(contract)
// No need to notify users of resolution, that's handled in resolve-market
return
}
if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
await handleUpdatedCloseTime(previousContract, contract, eventId)
}
})
async function handleUpdatedCloseTime(
previousContract: Contract,
contract: Contract,
eventId: string
) {
const contractUpdater = await getUser(contract.creatorId) const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater') if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
) {
let sourceText = '' let sourceText = ''
if ( if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString() sourceText = contract.closeTime.toString()
} else if (previousValue.question !== contract.question) { } else if (previousContract.question !== contract.question) {
sourceText = contract.question sourceText = contract.question
} }
@ -40,5 +55,64 @@ export const onUpdateContract = functions.firestore
sourceText, sourceText,
contract contract
) )
}
async function handleUnusedCommentBountyRefunds(contract: Contract) {
const outstandingCommentBounties = await getValues<Txn>(
firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY')
)
const commentBountiesOnThisContract = sortBy(
outstandingCommentBounties.filter(
(bounty) => bounty.data?.contractId === contract.id
),
(bounty) => bounty.createdTime
)
const [toBank, fromBank] = partition(
commentBountiesOnThisContract,
(bounty) => bounty.toType === 'BANK'
)
if (toBank.length <= fromBank.length) return
await firestore
.collection('contracts')
.doc(contract.id)
.update({ openCommentBounties: 0 })
const refunds = toBank.slice(fromBank.length)
await Promise.all(
refunds.map(async (extraBountyTxn) => {
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: extraBountyTxn.toId,
fromType: 'BANK',
toId: extraBountyTxn.fromId,
toType: 'USER',
amount: extraBountyTxn.amount,
token: 'M$',
category: 'REFUND_COMMENT_BOUNTY',
data: {
contractId: contract.id,
},
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(
`Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`,
result.status
)
log('message:', result.message)
} else {
log(
`Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`,
result.txn?.id
)
} }
}) })
)
}
const firestore = admin.firestore()

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils' import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions export const resetWeeklyEmailsFlags = functions
.runWith({ .runWith({
timeoutSeconds: 300, timeoutSeconds: 300,
memory: '4GB', memory: '4GB',
@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
privateUsers.map(async (user) => { privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({ return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false, weeklyTrendingEmailSent: false,
weeklyPortfolioUpdateEmailSent: false,
}) })
}) })
) )

View File

@ -1,12 +1,15 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Contract } from 'common/contract' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { log } from './utils' import { log } from './utils'
import { removeUndefinedProps } from '../../common/util/object'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
export const scoreContracts = functions.pubsub export const scoreContracts = functions
.schedule('every 1 hours') .runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 1 hours')
.onRun(async () => { .onRun(async () => {
await scoreContractsInternal() await scoreContractsInternal()
}) })
@ -14,11 +17,12 @@ const firestore = admin.firestore()
async function scoreContractsInternal() { async function scoreContractsInternal() {
const now = Date.now() const now = Date.now()
const lastHour = now - 60 * 60 * 1000 const hourAgo = now - HOUR_MS
const last3Days = now - 1000 * 60 * 60 * 24 * 3 const dayAgo = now - DAY_MS
const threeDaysAgo = now - DAY_MS * 3
const activeContractsSnap = await firestore const activeContractsSnap = await firestore
.collection('contracts') .collection('contracts')
.where('lastUpdatedTime', '>', lastHour) .where('lastUpdatedTime', '>', hourAgo)
.get() .get()
const activeContracts = activeContractsSnap.docs.map( const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract (doc) => doc.data() as Contract
@ -39,16 +43,33 @@ async function scoreContractsInternal() {
for (const contract of contracts) { for (const contract of contracts) {
const bets = await firestore const bets = await firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days) .where('createdTime', '>', threeDaysAgo)
.get() .get()
const bettors = bets.docs const bettors = bets.docs
.map((doc) => doc.data() as Bet) .map((doc) => doc.data() as Bet)
.map((bet) => bet.userId) .map((bet) => bet.userId)
const score = uniq(bettors).length const popularityScore = uniq(bettors).length
if (contract.popularityScore !== score)
const wasCreatedToday = contract.createdTime > dayAgo
let dailyScore: number | undefined
if (
contract.outcomeType === 'BINARY' &&
contract.mechanism === 'cpmm-1' &&
!wasCreatedToday
) {
const percentChange = Math.abs(contract.probChanges.day)
dailyScore = popularityScore * percentChange
}
if (
contract.popularityScore !== popularityScore ||
contract.dailyScore !== dailyScore
) {
await firestore await firestore
.collection('contracts') .collection('contracts')
.doc(contract.id) .doc(contract.id)
.update({ popularityScore: score }) .update(removeUndefinedProps({ popularityScore, dailyScore }))
}
} }
} }

View File

@ -0,0 +1,52 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'http://localhost:3000'
// Dev API key for Cause Exploration Prizes (@CEP)
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
// DEV API key for Criticism and Red Teaming (@CARTBot)
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
// Warning: Checking these in can be dangerous!
// Prod API key for @CEPBot
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`${DOMAIN}/api/v0/group/${slug}`)
return await resp.json()
}
async function getMarketsByGroupId(id: string) {
// API structure: /v0/group/by-id/[id]/markets
const resp = await fetch(`${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
async function addLiquidityById(id: string, amount: number) {
const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
amount: amount,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug('cart-contest')
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
// Resolve each market to NO
for (const market of markets.slice(0, 3)) {
console.log(market.slug, market.totalLiquidity)
const resp = await addLiquidityById(market.id, 200)
console.log(resp)
}
}
main()

View File

@ -0,0 +1,115 @@
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
import { data } from './criticism-and-red-teaming'
// Dev API key for Cause Exploration Prizes (@CEP)
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
// DEV API key for Criticism and Red Teaming (@CARTBot)
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
type CEPSubmission = {
title: string
author?: string
link: string
}
// Use the API to create a new market for this Cause Exploration Prize submission
async function postMarket(submission: CEPSubmission) {
const { title, author } = submission
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcomeType: 'BINARY',
question: `"${title}" by ${author ?? 'anonymous'}`,
description: makeDescription(submission),
closeTime: Date.parse('2022-09-30').valueOf(),
initialProb: 10,
// Super secret options:
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
visibility: 'unlisted',
// TODO: Increase liquidity?
}),
})
const data = await response.json()
console.log('Created market:', data.slug)
}
async function postAll() {
for (const submission of data.slice(0, 3)) {
await postMarket(submission)
}
}
postAll()
/* Example curl request:
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Key {...}'
--data-raw '{"outcomeType":"BINARY", \
"question":"Is there life on Mars?", \
"description":"I'm not going to type some long ass example description.", \
"closeTime":1700000000000, \
"initialProb":25}'
*/
function makeDescription(submission: CEPSubmission) {
const { title, author, link } = submission
return {
content: [
{
content: [
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
{
marks: [
{
attrs: {
target: '_blank',
href: link,
class:
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
},
type: 'link',
},
],
type: 'text',
text: title,
},
{ text: '" win any prize in the ', type: 'text' },
{
text: 'EA Criticism and Red Teaming Contest',
type: 'text',
marks: [
{
attrs: {
target: '_blank',
class:
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
},
type: 'link',
},
],
},
{ text: '?', type: 'text' },
],
type: 'paragraph',
},
{ type: 'paragraph' },
{
type: 'iframe',
attrs: {
allowfullscreen: true,
src: link,
frameborder: 0,
},
},
],
type: 'doc',
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
import * as fs from 'fs'
import * as puppeteer from 'puppeteer'
export function scrapeEA(contestLink: string, fileName: string) {
;(async () => {
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto(contestLink)
let loadMoreButton = await page.$('.LoadMore-root')
while (loadMoreButton) {
await loadMoreButton.click()
await page.waitForNetworkIdle()
loadMoreButton = await page.$('.LoadMore-root')
}
/* Run javascript inside the page */
const data = await page.evaluate(() => {
const list = []
const items = document.querySelectorAll('.PostsItem2-root')
for (const item of items) {
const link =
'https://forum.effectivealtruism.org' +
item?.querySelector('a')?.getAttribute('href')
// Replace '&amp;' with '&'
const clean = (str: string | undefined) => str?.replace(/&amp;/g, '&')
list.push({
title: clean(item?.querySelector('a>span>span')?.innerHTML),
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
link: link,
})
}
return list
})
fs.writeFileSync(
`./src/scripts/contest/${fileName}.ts`,
`export const data = ${JSON.stringify(data, null, 2)}`
)
console.log(data)
await browser.close()
})()
}
scrapeEA(
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
'criticism-and-red-teaming'
)

View File

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

View File

@ -28,6 +28,8 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -60,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity) addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)
@ -69,6 +73,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -0,0 +1,17 @@
import { APIError, newEndpoint } from './api'
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
import { isProd } from './utils'
// Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint(
{ method: 'GET', memory: '4GiB' },
async (_req) => {
if (isProd())
throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here
await sendPortfolioUpdateEmailsToAllUsers()
return { success: true }
}
)

View File

@ -0,0 +1,162 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
import {
CommentBountyDepositTxn,
CommentBountyWithdrawalTxn,
} from '../../common/txn'
import { runTxn } from './transact'
import { Comment } from '../../common/comment'
import { createBountyNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
const awardBodySchema = z.object({
contractId: z.string(),
commentId: z.string(),
amount: z.number().gt(0),
})
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.balance < amount)
throw new APIError(400, 'Insufficient user balance')
const newCommentBountyTxn = {
fromId: user.id,
fromType: 'USER',
toId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
toType: 'BANK',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
},
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
} as CommentBountyDepositTxn
const result = await runTxn(transaction, newCommentBountyTxn)
transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
})
)
return result
})
})
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
const res = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.id !== contract.creatorId)
throw new APIError(
400,
'Only contract creator can award comment bounties'
)
const commentDoc = firestore.doc(
`contracts/${contractId}/comments/${commentId}`
)
const commentSnap = await transaction.get(commentDoc)
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
const comment = commentSnap.data() as Comment
const amountAvailable = contract.openCommentBounties ?? 0
if (amountAvailable < amount)
throw new APIError(400, 'Insufficient open bounty balance')
const newCommentBountyTxn = {
fromId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
fromType: 'BANK',
toId: comment.userId,
toType: 'USER',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
commentId,
},
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
} as CommentBountyWithdrawalTxn
const result = await runTxn(transaction, newCommentBountyTxn)
await transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: amountAvailable - amount,
})
)
await transaction.update(
commentDoc,
removeUndefinedProps({
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
})
)
return { ...result, comment, contract, user }
})
if (res.txn?.id) {
const { comment, contract, user } = res
await createBountyNotification(
user,
comment.userId,
amount,
res.txn.id,
contract,
comment.id
)
}
return res
})
const firestore = admin.firestore()

View File

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

View File

@ -17,38 +17,57 @@ import {
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Group } from 'common/group' import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise'
const firestore = admin.firestore() const firestore = admin.firestore()
export const updateMetrics = functions export const updateMetrics = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 }) .runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') .pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore) .onRun(updateMetricsCore)
export async function updateMetricsCore() { export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories, groups] = console.log('Loading users')
await Promise.all([ const users = await getValues<User>(firestore.collection('users'))
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')), console.log('Loading contracts')
getValues<Bet>(firestore.collectionGroup('bets')), const contracts = await getValues<Contract>(firestore.collection('contracts'))
getValues<PortfolioMetrics>(
console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>(
firestore firestore
.collectionGroup('portfolioHistory') .collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
), )
getValues<Group>(firestore.collection('groups')),
])
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
console.log('Loading bets')
const contractBets = await batchedWaitAll(
contracts
.filter((c) => c.id)
.map(
(c) => () =>
getValues<Bet>(
firestore.collection('contracts').doc(c.id).collection('bets')
)
),
100
)
const bets = contractBets.flat()
console.log('Loading group contracts')
const contractsByGroup = await Promise.all( const contractsByGroup = await Promise.all(
groups.map((group) => { groups.map((group) =>
return getValues( getValues(
firestore firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.collection('groupContracts') .collection('groupContracts')
) )
}) )
) )
log( log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`

View File

@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
const firestore = admin.firestore() const firestore = admin.firestore()
const numberOfDays = 90 const numberOfDays = 180
const getBetsQuery = (startTime: number, endTime: number) => const getBetsQuery = (startTime: number, endTime: number) =>
firestore firestore
@ -343,6 +343,6 @@ export const updateStatsCore = async () => {
} }
export const updateStats = functions export const updateStats = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 }) .runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes') .pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore) .onRun(updateStatsCore)

View File

@ -170,3 +170,7 @@ export const chargeUser = (
export const getContractPath = (contract: Contract) => { export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}` return `/${contract.creatorUsername}/${contract.slug}`
} }
export function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() {
? await getAllPrivateUsers() ? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => { const privateUsersToSendEmailsTo = privateUsers
.filter((user) => {
return ( return (
user.notificationPreferences.trending_markets.includes('email') && user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent !user.weeklyTrendingEmailSent
) )
}) })
.slice(150) // Send the emails out in batches
log( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
trendingContracts.map((c) => c.question).join('\n ') trendingContracts.map((c) => c.question).join('\n ')
) )
// TODO: convert to Promise.all
for (const privateUser of privateUsersToSendEmailsTo) { for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) { if (!privateUser.email) {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() {
}) })
if (contractsAvailableToSend.length < numContractsToSend) { if (contractsAvailableToSend.length < numContractsToSend) {
log('not enough new, unbet-on contracts to send to user', privateUser.id) log('not enough new, unbet-on contracts to send to user', privateUser.id)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyTrendingEmailSent: true,
})
continue continue
} }
// choose random subset of contracts to send to user // choose random subset of contracts to send to user

View File

@ -0,0 +1,289 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract, CPMMContract } from '../../common/contract'
import {
getAllPrivateUsers,
getPrivateUser,
getUser,
getValue,
getValues,
isProd,
log,
} from './utils'
import { filterDefined } from '../../common/util/array'
import { DAY_MS } from '../../common/util/time'
import { partition, sortBy, sum, uniq } from 'lodash'
import { Bet } from '../../common/bet'
import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
import { sendWeeklyPortfolioUpdateEmail } from './emails'
import { contractUrl } from './utils'
import { Txn } from '../../common/txn'
import { formatMoney } from '../../common/util/format'
import { getContractBetMetrics } from '../../common/calculate'
export const weeklyPortfolioUpdateEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 5')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendPortfolioUpdateEmailsToAllUsers()
})
const firestore = admin.firestore()
export async function sendPortfolioUpdateEmailsToAllUsers() {
const privateUsers = isProd()
? // ian & stephen's ids
// filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
// ])
await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers
.filter((user) => {
return isProd()
? user.notificationPreferences.profit_loss_updates.includes('email') &&
!user.weeklyPortfolioUpdateEmailSent
: user.notificationPreferences.profit_loss_updates.includes('email')
})
// Send emails in batches
.slice(0, 200)
log(
'Sending weekly portfolio emails to',
privateUsersToSendEmailsTo.length,
'users'
)
const usersBets: { [userId: string]: Bet[] } = {}
// get all bets made by each user
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Bet>(
firestore.collectionGroup('bets').where('userId', '==', user.id)
).then((bets) => {
usersBets[user.id] = bets
})
})
)
const usersToContractsCreated: { [userId: string]: Contract[] } = {}
// Get all contracts created by each user
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Contract>(
firestore
.collection('contracts')
.where('creatorId', '==', user.id)
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
).then((contracts) => {
usersToContractsCreated[user.id] = contracts
})
})
)
// Get all txns the users received over the past week
const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Txn>(
firestore
.collection(`txns`)
.where('toId', '==', user.id)
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
).then((txn) => {
usersToTxnsReceived[user.id] = txn
})
})
)
// Get a flat map of all the bets that users made to get the contracts they bet on
const contractsUsersBetOn = filterDefined(
await Promise.all(
uniq(
Object.values(usersBets).flatMap((bets) =>
bets.map((bet) => bet.contractId)
)
).map((contractId) =>
getValue<Contract>(firestore.collection('contracts').doc(contractId))
)
)
)
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
)
const contractsBetOnInLastWeek = uniq(
userBets
.filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
.map((bet) => bet.contractId)
)
const totalTips = sum(
usersToTxnsReceived[privateUser.id]
.filter((txn) => txn.category === 'TIP')
.map((txn) => txn.amount)
)
const greenBg = 'rgba(0,160,0,0.2)'
const redBg = 'rgba(160,0,0,0.2)'
const clearBg = 'rgba(255,255,255,0)'
const roundedProfit =
Math.round(user.profitCached.weekly) === 0
? 0
: Math.floor(user.profitCached.weekly)
const performanceData = {
profit: formatMoney(user.profitCached.weekly),
profit_style: `background-color: ${
roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
}`,
markets_created:
usersToContractsCreated[privateUser.id].length.toString(),
tips_received: formatMoney(totalTips),
unique_bettors: usersToTxnsReceived[privateUser.id]
.filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
.length.toString(),
markets_traded: contractsBetOnInLastWeek.length.toString(),
prediction_streak:
(user.currentBettingStreak?.toString() ?? '0') + ' days',
// More options: bonuses, tips given,
} as OverallPerformanceData
const investmentValueDifferences = sortBy(
filterDefined(
contractsUserBetOn.map((contract) => {
const cpmmContract = contract as CPMMContract
if (cpmmContract === undefined || cpmmContract.prob === undefined)
return
const bets = userBets.filter(
(bet) => bet.contractId === contract.id
)
const previousBets = bets.filter(
(b) => b.createdTime < Date.now() - 7 * DAY_MS
)
const betsInLastWeek = bets.filter(
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
)
const marketProbabilityAWeekAgo =
cpmmContract.prob - cpmmContract.probChanges.week
const currentMarketProbability = cpmmContract.resolutionProbability
? cpmmContract.resolutionProbability
: cpmmContract.prob
// TODO: returns 0 for resolved markets - doesn't include them
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
previousBets,
contract,
marketProbabilityAWeekAgo
)
const currentBetsMadeAWeekAgoValue =
computeInvestmentValueCustomProb(
previousBets,
contract,
currentMarketProbability
)
const betsMadeInLastWeekProfit = getContractBetMetrics(
contract,
betsInLastWeek
).profit
const profit =
betsMadeInLastWeekProfit +
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
return {
currentValue: currentBetsMadeAWeekAgoValue,
pastValue: betsMadeAWeekAgoValue,
profit,
contractSlug: contract.slug,
marketProbAWeekAgo: marketProbabilityAWeekAgo,
questionTitle: contract.question,
questionUrl: contractUrl(contract),
questionProb: cpmmContract.resolution
? cpmmContract.resolution
: Math.round(cpmmContract.prob * 100) + '%',
profitStyle: `color: ${
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
};`,
} as PerContractInvestmentsData
})
),
(differences) => Math.abs(differences.profit)
).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
),
(investmentsData: PerContractInvestmentsData) => {
return investmentsData.profit > 0
}
)
// pick 3 winning investments and 3 losing investments
const topInvestments = winningInvestments.slice(0, 2)
const worstInvestments = losingInvestments.slice(0, 2)
// if no bets in the last week ANd no market movers AND no markets created, don't send email
if (
contractsBetOnInLastWeek.length === 0 &&
topInvestments.length === 0 &&
worstInvestments.length === 0 &&
usersToContractsCreated[privateUser.id].length === 0
) {
log(
'No bets in last week, no market movers, no markets created. Not sending an email.'
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
return
}
await sendWeeklyPortfolioUpdateEmail(
user,
privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
performanceData
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to users:', count)
})
)
}
export type PerContractInvestmentsData = {
questionTitle: string
questionUrl: string
questionProb: string
profitStyle: string
currentValue: number
pastValue: number
profit: number
}
export type OverallPerformanceData = {
profit: string
prediction_streak: string
markets_traded: string
profit_style: string
tips_received: string
markets_created: string
unique_bettors: string
}

View File

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

View File

@ -5,7 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size' import { Row } from './layout/row'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -34,16 +34,19 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount) const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return ( return (
<>
<Col className={className}> <Col className={className}>
<label className="input-group mb-4"> <label className="font-sm md:font-lg relative">
<span className="bg-gray-200 text-sm">{label}</span> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label}
</span>
<input <input
className={clsx( className={clsx(
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error', error && 'input-error',
'w-24 md:w-auto',
inputClassName inputClassName
)} )}
ref={inputRef} ref={inputRef}
@ -52,7 +55,6 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
@ -60,7 +62,7 @@ export function AmountInput(props: {
</label> </label>
{error && ( {error && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
<> <>
Not enough funds. Not enough funds.
@ -74,6 +76,7 @@ export function AmountInput(props: {
</div> </div>
)} )}
</Col> </Col>
</>
) )
} }
@ -136,6 +139,7 @@ export function BuyAmountInput(props: {
return ( return (
<> <>
<Row className="gap-4">
<AmountInput <AmountInput
amount={amount} amount={amount}
onChange={onAmountChange} onChange={onAmountChange}
@ -153,10 +157,11 @@ export function BuyAmountInput(props: {
max="205" max="205"
value={getRaw(amount ?? 0)} value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))} onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg only-thumb z-40 mb-2 xl:hidden" className="range range-lg only-thumb my-auto align-middle xl:hidden"
step="5" step="5"
/> />
)} )}
</Row>
</> </>
) )
} }

View File

@ -1,139 +0,0 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Col } from '../layout/col'
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' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/>
</div>
)
}
export function DailyPercentChart(props: {
startDate: number
dailyPercent: number[]
small?: boolean
excludeFirstDays?: number
}) {
const { dailyPercent, startDate, small, excludeFirstDays } = props
const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyPercent)
.map(([date, percent]) => ({
x: date,
y: percent,
}))
.slice(excludeFirstDays ?? 0)
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
return (
<div
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
yScale={{ type: 'linear', stacked: false }}
xScale={{
type: 'time',
}}
axisLeft={{
format: formatPercent,
}}
axisBottom={{
tickValues: bottomAxisTicks,
format: (date) => dayjs(date).format('MMM DD'),
}}
colors={{ datum: 'color' }}
pointSize={0}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} isPercent />
}}
/>
</div>
)
}
function Tooltip(props: { point: Point; isPercent?: boolean }) {
const { point, isPercent } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong>{' '}
{isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>
)
}

View File

@ -182,18 +182,16 @@ export function AnswerBetPanel(props: {
</Col> </Col>
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<WarningConfirmationButton <WarningConfirmationButton
size="xl"
marketType="freeResponse"
amount={betAmount}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled}
openModalButtonClass={clsx( color={'indigo'}
'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : ''
)}
/> />
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(false) setIsSubmitting(false)
} }
const resolutionButtonClass =
resolveOption === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: resolveOption === 'CHOOSE' && answers.length
? 'btn-primary'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Row className="justify-between"> <Row className="justify-between">
@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: {
Clear Clear
</button> </button>
)} )}
<ResolveConfirmationButton <ResolveConfirmationButton
color={
resolveOption === 'CANCEL'
? 'yellow'
: resolveOption === 'CHOOSE' && answers.length
? 'green'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'blue'
: 'indigo'
}
disabled={
!resolveOption ||
(resolveOption === 'CHOOSE' && !answers.length) ||
(resolveOption === 'CHOOSE_MULTIPLE' &&
(!(answers.length > 1) ||
!answers.every((answer) => chosenAnswers[answer] > 0)))
}
onResolve={onResolve} onResolve={onResolve}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
openModalButtonClass={resolutionButtonClass}
submitButtonClass={resolutionButtonClass}
/> />
</Row> </Row>
</Col> </Col>

View File

@ -1,238 +0,0 @@
import { DatumValue } from '@nivo/core'
import { ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs'
import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
height?: number
}) {
const { contract, bets, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract
const now = Date.now()
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
bets,
contract
)
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? now
)
const { width } = useWindowSize()
const isLargeWidth = !width || width > 800
const labelLength = isLargeWidth ? 50 : 20
// Add a fake datapoint so the line continues to the right
const endTime = latestTime.valueOf()
const times = sortBy([
createdTime,
...bets.map((bet) => bet.createdTime),
endTime,
])
const dateTimes = times.map((time) => new Date(time))
const data = sortedOutcomes.map((outcome) => {
const betProbs = probsByOutcome[outcome]
// Add extra point for contract start and end.
const probs = [0, ...betProbs, betProbs[betProbs.length - 1]]
const points = probs.map((prob, i) => ({
x: dateTimes[i],
y: Math.round(prob * 100),
}))
const answer =
answers?.find((answer) => answer.id === outcome)?.text ?? 'None'
const answerText =
answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '')
return { id: answerText, data: points }
})
data.reverse()
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2
const startDate = dayjs(contract.createdTime)
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
return (
<div
className="w-full"
style={{ height: height ?? (isLargeWidth ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: 100, type: 'linear', stacked: true }}
yFormat={formatPercent}
gridYValues={yTickValues}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'time',
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={[
'#fca5a5', // red-300
'#a5b4fc', // indigo-300
'#86efac', // green-300
'#fef08a', // yellow-200
'#fdba74', // orange-300
'#c084fc', // purple-400
]}
pointSize={0}
curve="stepAfter"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
areaOpacity={1}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
legends={[
{
anchor: 'top-left',
direction: 'column',
justify: false,
translateX: isLargeWidth ? 5 : 2,
translateY: 0,
itemsSpacing: 0,
itemTextColor: 'black',
itemDirection: 'left-to-right',
itemWidth: isLargeWidth ? 288 : 138,
itemHeight: 20,
itemBackground: 'white',
itemOpacity: 0.9,
symbolSize: 12,
effects: [
{
on: 'hover',
style: {
itemBackground: 'rgba(255, 255, 255, 1)',
itemOpacity: 1,
},
},
],
},
]}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
return `${Math.round(+y.toString())}%`
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'
}
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}
const computeProbsByOutcome = (
bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter)
)
return (
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
maxProb > 0.02 &&
totalBets[outcome] > 0.000000001
)
})
const trackedOutcomes = sortBy(
outcomes,
(outcome) => -1 * getOutcomeProbability(contract, outcome)
).slice(0, NUM_LINES)
const probsByOutcome = Object.fromEntries(
trackedOutcomes.map((outcome) => [outcome, [] as number[]])
)
const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
)
for (const bet of bets) {
const { outcome, shares } = bet
sharesByOutcome[outcome] += shares
const sharesSquared = sumBy(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)
for (const outcome of trackedOutcomes) {
probsByOutcome[outcome].push(
sharesByOutcome[outcome] ** 2 / sharesSquared
)
}
}
return { probsByOutcome, sortedOutcomes: trackedOutcomes }
}

View File

@ -1,4 +1,4 @@
import { sortBy, partition, sum, uniq } from 'lodash' import { sortBy, partition, sum } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel' import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel' import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import clsx from 'clsx' import clsx from 'clsx'
@ -56,6 +55,11 @@ export function AnswersPanel(props: {
), ),
] ]
const answerItems = sortBy(
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
(answer) => -getOutcomeProbability(contract, answer.id)
)
const user = useUser() const user = useUser()
const [resolveOption, setResolveOption] = useState< const [resolveOption, setResolveOption] = useState<
@ -67,12 +71,6 @@ export function AnswersPanel(props: {
const chosenTotal = sum(Object.values(chosenAnswers)) const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
user
)
const onChoose = (answerId: string, prob: number) => { const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') { if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob }) setChosenAnswers({ [answerId]: prob })
@ -123,28 +121,26 @@ export function AnswersPanel(props: {
))} ))}
{!resolveOption && ( {!resolveOption && (
<div className={clsx('flow-root pr-2 md:pr-0')}> <Col
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> className={clsx(
'gap-2 pr-2 md:pr-0',
tradingAllowed(contract) ? '' : '-mb-6'
)}
>
{answerItems.map((item) => ( {answerItems.map((item) => (
<div key={item.id} className={'relative pb-2'}> <OpenAnswer key={item.id} answer={item} contract={contract} />
<div className="relative flex items-start space-x-3">
<OpenAnswer {...item} />
</div>
</div>
))} ))}
<Row className={'justify-end'}>
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
<Button <Button
color={'gray-white'} className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)} onClick={() => setShowAllAnswers(true)}
size={'md'} size="md"
> >
Show More Show More
</Button> </Button>
)} )}
</Row> </Col>
</div>
</div>
)} )}
{answers.length <= 1 && ( {answers.length <= 1 && (
@ -175,35 +171,9 @@ export function AnswersPanel(props: {
) )
} }
function getAnswerItems(
contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[],
user: User | undefined | null
) {
let outcomes = uniq(answers.map((answer) => answer.number.toString()))
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()
return outcomes
.map((outcome) => {
const answer = answers.find((answer) => answer.id === outcome) as Answer
//unnecessary
return {
id: outcome,
type: 'answer' as const,
contract,
answer,
user,
}
})
.filter((group) => group.answer)
}
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
type: string
}) { }) {
const { answer, contract } = props const { answer, contract } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
@ -212,7 +182,7 @@ function OpenAnswer(props: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}> <Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Modal open={open} setOpen={setOpen} position="center"> <Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
@ -229,21 +199,15 @@ function OpenAnswer(props: {
/> />
<Row className="my-4 gap-3"> <Row className="my-4 gap-3">
<div className="px-1"> <Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1"> <Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered <UserLink username={username} name={name} /> answered
</div> </div>
<Col className="align-items justify-between gap-4 sm:flex-row"> <Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg"> <Linkify className="whitespace-pre-line text-lg" text={text} />
<Linkify text={text} /> <Row className="align-items items-center justify-end gap-4">
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<span <span
className={clsx( className={clsx(
'text-2xl', 'text-2xl',
@ -259,7 +223,6 @@ function OpenAnswer(props: {
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> />
</div>
</Row> </Row>
</Col> </Col>
</Col> </Col>

View File

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

View File

@ -8,13 +8,14 @@ export function Avatar(props: {
username?: string username?: string
avatarUrl?: string avatarUrl?: string
noLink?: boolean noLink?: boolean
size?: number | 'xs' | 'sm' size?: number | 'xxs' | 'xs' | 'sm'
className?: string className?: string
}) { }) {
const { username, noLink, size, className } = props const { username, noLink, size, className } = props
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s =
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4 const sizeInPx = s * 4
const onClick = const onClick =
@ -40,7 +41,7 @@ export function Avatar(props: {
style={{ maxWidth: `${s * 0.25}rem` }} style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl} src={avatarUrl}
onClick={onClick} onClick={onClick}
alt={username} alt={`${username ?? 'Unknown user'} avatar`}
onError={() => { onError={() => {
// If the image doesn't load, clear the avatarUrl to show the default // If the image doesn't load, clear the avatarUrl to show the default
// Mostly for localhost, when getting a 403 from googleusercontent // Mostly for localhost, when getting a 403 from googleusercontent

View File

@ -0,0 +1,46 @@
import clsx from 'clsx'
import { ContractComment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
export function AwardBountyButton(prop: {
comment: ContractComment
contract: Contract
}) {
const { comment, contract } = prop
const me = useUser()
const submit = () => {
const data = {
amount: COMMENT_BOUNTY_AMOUNT,
commentId: comment.id,
contractId: contract.id,
}
awardCommentBounty(data)
.then((_) => {
console.log('success')
track('award comment bounty', data)
})
.catch((reason) => console.log('Server error:', reason))
track('award comment bounty', data)
}
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div />
return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
<TextButton className={'font-bold'} onClick={submit}>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton>
</Row>
)
}

View File

@ -1,8 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { SimpleBetPanel } from './bet-panel' import { BuyPanel, SimpleBetPanel } from './bet-panel'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import {
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
@ -10,6 +14,10 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -64,7 +72,6 @@ export default function BetButton(props: {
<SimpleBetPanel <SimpleBetPanel
className={betPanelClassName} className={betPanelClassName}
contract={contract} contract={contract}
selected="YES"
onBetSuccess={() => setOpen(false)} onBetSuccess={() => setOpen(false)}
hasShares={hasYesShares || hasNoShares} hasShares={hasYesShares || hasNoShares}
/> />
@ -72,3 +79,49 @@ export default function BetButton(props: {
</> </>
) )
} }
export function BinaryMobileBetting(props: { contract: BinaryContract }) {
const { contract } = props
const user = useUser()
if (user) {
return <SignedInBinaryMobileBetting contract={contract} user={user} />
} else {
return (
<Col className="w-full">
<BetSignUpPrompt className="w-full" />
<PlayMoneyDisclaimer />
</Col>
)
}
}
export function SignedInBinaryMobileBetting(props: {
contract: BinaryContract
user: User
}) {
const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? []
return (
<>
<Col className="w-full gap-2 px-1">
<Col>
<BuyPanel
hidden={false}
contract={contract as CPMMBinaryContract}
user={user}
unfilledBets={unfilledBets}
mobileView={true}
/>
</Col>
<SellRow
contract={contract}
user={user}
className={
'border-greyscale-3 bg-greyscale-1 rounded-md border-2 px-4 py-2'
}
/>
</Col>
</>
)
}

View File

@ -43,6 +43,10 @@ import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { isAndroid, isIOS } from 'web/lib/util/device' import { isAndroid, isIOS } from 'web/lib/util/device'
import { WarningConfirmationButton } from './warning-confirmation-button' import { WarningConfirmationButton } from './warning-confirmation-button'
import { MarketIntroPanel } from './market-intro-panel' import { MarketIntroPanel } from './market-intro-panel'
import { Modal } from './layout/modal'
import { Title } from './title'
import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -105,11 +109,10 @@ export function BetPanel(props: {
export function SimpleBetPanel(props: { export function SimpleBetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
selected?: 'YES' | 'NO'
hasShares?: boolean hasShares?: boolean
onBetSuccess?: () => void onBetSuccess?: () => void
}) { }) {
const { contract, className, selected, hasShares, onBetSuccess } = props const { contract, className, hasShares, onBetSuccess } = props
const user = useUser() const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -139,7 +142,6 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<LimitOrderPanel <LimitOrderPanel
@ -162,38 +164,47 @@ export function SimpleBetPanel(props: {
) )
} }
function BuyPanel(props: { export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: Bet[]
hidden: boolean hidden: boolean
selected?: 'YES' | 'NO'
onBuySuccess?: () => void onBuySuccess?: () => void
mobileView?: boolean
}) { }) {
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected) const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const [inputRef, focusAmountInput] = useFocus() const [inputRef, focusAmountInput] = useFocus()
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice) setOutcome(choice)
setWasSubmitted(false)
if (!isIOS() && !isAndroid()) { if (!isIOS() && !isAndroid()) {
focusAmountInput() focusAmountInput()
} }
} }
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
if (outcome === choice) {
setOutcome(undefined)
} else {
setOutcome(choice)
}
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
}
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
if (!outcome) { if (!outcome) {
setOutcome('YES') setOutcome('YES')
@ -214,9 +225,13 @@ function BuyPanel(props: {
.then((r) => { .then((r) => {
console.log('placed bet. Result:', r) console.log('placed bet. Result:', r)
setIsSubmitting(false) setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined) setBetAmount(undefined)
if (onBuySuccess) onBuySuccess() if (onBuySuccess) onBuySuccess()
else {
toast('Trade submitted!', {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
}
}) })
.catch((e) => { .catch((e) => {
if (e instanceof APIError) { if (e instanceof APIError) {
@ -249,6 +264,7 @@ function BuyPanel(props: {
unfilledBets as LimitBet[] unfilledBets as LimitBet[]
) )
const [seeLimit, setSeeLimit] = useState(false)
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame = const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb) formatPercent(resultProb) === formatPercent(initialProb)
@ -281,22 +297,79 @@ function BuyPanel(props: {
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
{isPseudoNumeric ? 'Direction' : 'Outcome'}
</div>
<YesNoSelector <YesNoSelector
className="mb-4" className="mb-4"
btnClassName="flex-1" btnClassName="flex-1"
selected={outcome} selected={outcome}
onSelect={(choice) => onBetChoice(choice)} onSelect={(choice) => {
if (mobileView) {
mobileOnBetChoice(choice)
} else {
onBetChoice(choice)
}
}}
isPseudoNumeric={isPseudoNumeric} isPseudoNumeric={isPseudoNumeric}
/> />
<Row className="my-3 justify-between text-left text-sm text-gray-500"> <Col
Amount className={clsx(
<span className={'xl:hidden'}> mobileView
Balance: {formatMoney(user?.balance ?? 0)} ? outcome === 'NO'
? 'bg-red-25'
: outcome === 'YES'
? 'bg-teal-50'
: 'hidden'
: 'bg-white',
mobileView ? 'rounded-lg px-4 py-2' : 'px-0'
)}
>
<Row className="mt-3 w-full gap-3">
<Col className="w-1/2 text-sm">
<Col className="text-greyscale-4 flex-nowrap whitespace-nowrap text-xs">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>Payout if {outcome ?? 'YES'}</>
)}
</div>
</Col>
<div>
<span className="whitespace-nowrap text-xl">
{formatMoney(currentPayout)}
</span> </span>
<span className="text-greyscale-4 text-xs">
{' '}
+{currentReturnPercent}
</span>
</div>
</Col>
<Col className="w-1/2 text-sm">
<div className="text-greyscale-4 text-xs">
{isPseudoNumeric ? 'Estimated value' : 'New Probability'}
</div>
{probStayedSame ? (
<div className="text-xl">{format(initialProb)}</div>
) : (
<div className="text-xl">
{format(resultProb)}
<span className={clsx('text-greyscale-4 text-xs')}>
{isPseudoNumeric ? (
<></>
) : (
<>
{' '}
{outcome != 'NO' && '+'}
{format(resultProb - initialProb)}
</>
)}
</span>
</div>
)}
</Col>
</Row>
<Row className="text-greyscale-4 mt-4 mb-1 justify-between text-left text-xs">
Amount
</Row> </Row>
<BuyAmountInput <BuyAmountInput
@ -310,63 +383,46 @@ function BuyPanel(props: {
showSliderOnMobile showSliderOnMobile
/> />
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
{probStayedSame ? (
<div>{format(initialProb)}</div>
) : (
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
)}
</Row>
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
</>
)}
</div>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(currentPayout)}
</span>
(+{currentReturnPercent})
</div>
</Row>
</Col>
<Spacer h={8} /> <Spacer h={8} />
{user && ( {user && (
<WarningConfirmationButton <WarningConfirmationButton
marketType="binary"
amount={betAmount}
warning={warning} warning={warning}
onSubmit={submitBet} onSubmit={submitBet}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled || outcome === undefined}
openModalButtonClass={clsx( size="xl"
'btn mb-2 flex-1', color={outcome === 'NO' ? 'red' : 'green'}
betDisabled
? 'btn-disabled'
: outcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500'
)}
/> />
)} )}
<button
{wasSubmitted && <div className="mt-4">Trade submitted!</div>} className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden"
onClick={() => setSeeLimit(true)}
>
Advanced
</button>
<Modal
open={seeLimit}
setOpen={setSeeLimit}
position="center"
className="rounded-lg bg-white px-4 pb-4"
>
<Title text="Limit Order" />
<LimitOrderPanel
hidden={!seeLimit}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<LimitBets
contract={contract}
bets={unfilledBets as LimitBet[]}
className="mt-4"
/>
</Modal>
</Col>
</Col> </Col>
) )
} }
@ -389,7 +445,6 @@ function LimitOrderPanel(props: {
const betChoice = 'YES' const betChoice = 'YES'
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const rangeError = const rangeError =
lowLimitProb !== undefined && lowLimitProb !== undefined &&
@ -437,7 +492,6 @@ function LimitOrderPanel(props: {
const noAmount = shares * (1 - (noLimitProb ?? 0)) const noAmount = shares * (1 - (noLimitProb ?? 0))
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
} }
@ -482,7 +536,6 @@ function LimitOrderPanel(props: {
.then((r) => { .then((r) => {
console.log('placed bet. Result:', r) console.log('placed bet. Result:', r)
setIsSubmitting(false) setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined) setBetAmount(undefined)
setLowLimitProb(undefined) setLowLimitProb(undefined)
setHighLimitProb(undefined) setHighLimitProb(undefined)
@ -718,8 +771,6 @@ function LimitOrderPanel(props: {
: `Submit order${hasTwoBets ? 's' : ''}`} : `Submit order${hasTwoBets ? 's' : ''}`}
</button> </button>
)} )}
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
</Col> </Col>
) )
} }
@ -866,11 +917,7 @@ export function SellPanel(props: {
<> <>
<AmountInput <AmountInput
amount={ amount={
amount amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
? Math.round(amount) === 0
? 0
: Math.floor(amount)
: undefined
} }
onChange={onAmountChange} onChange={onAmountChange}
label="Qty" label="Qty"

View File

@ -0,0 +1,120 @@
import { sumBy } from 'lodash'
import clsx from 'clsx'
import { Bet } from 'web/lib/firebase/bets'
import { formatMoney, formatWithCommas } from 'common/util/format'
import { Col } from './layout/col'
import { Contract } from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { YesLabel, NoLabel } from './outcome-label'
import {
calculatePayout,
getContractBetMetrics,
getProbability,
} from 'common/calculate'
import { InfoTooltip } from './info-tooltip'
import { ProfitBadge } from './profit-badge'
export function BetsSummary(props: {
contract: Contract
userBets: Bet[]
className?: string
}) {
const { contract, className } = props
const { resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const bets = props.userBets.filter((b) => !b.isAnte)
const { profitPercent, payout, profit, invested } = getContractBetMetrics(
contract,
bets
)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const position = yesWinnings - noWinnings
const prob = isBinary ? getProbability(contract) : 0
const expectation = prob * yesWinnings + (1 - prob) * noWinnings
if (bets.length === 0) return <></>
return (
<Col className={clsx(className, 'gap-4')}>
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : isBinary ? (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Position{' '}
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." />
</div>
<div className="whitespace-nowrap">
{position > 1e-7 ? (
<>
<YesLabel /> {formatWithCommas(position)}
</>
) : position < -1e-7 ? (
<>
<NoLabel /> {formatWithCommas(-position)}
</>
) : (
'——'
)}
</div>
</Col>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expectation{''}
<InfoTooltip text="The estimated payout of your position using the current market probability." />
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
<Col className="hidden sm:inline">
<div className="whitespace-nowrap text-sm text-gray-500">
Invested{' '}
<InfoTooltip text="Cash currently invested in this market." />
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
{isBinary && !resolution && (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expectation{' '}
<InfoTooltip text="The estimated payout of your position using the current market probability." />
</div>
<div className="whitespace-nowrap">{formatMoney(expectation)}</div>
</Col>
)}
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Profit{' '}
<InfoTooltip text="Includes both realized & unrealized gains/losses." />
</div>
<div className="whitespace-nowrap">
{formatMoney(profit)}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
</Row>
</Col>
)
}

View File

@ -2,7 +2,6 @@ import Link from 'next/link'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
@ -22,7 +21,7 @@ import {
import { Row } from './layout/row' import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { OutcomeLabel } from './outcome-label'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { import {
@ -38,14 +37,19 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts' import { useUserBetContracts } from 'web/hooks/use-contracts'
import { BetsSummary } from './bet-summary'
import { ProfitBadge } from './profit-badge'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = usePersistentState<BetSort>('newest', {
const [filter, setFilter] = useState<BetFilter>('open') key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
})
const [filter, setFilter] = usePersistentState<BetFilter>('all', {
key: 'bets-list-filter',
store: storageStore(safeLocalStorage()),
})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const start = page * CONTRACTS_PER_PAGE const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE
@ -155,34 +165,25 @@ export function BetsList(props: { user: User }) {
(c) => contractsMetrics[c.id].netPayout (c) => contractsMetrics[c.id].netPayout
) )
const totalPnl = user.profitCached.allTime
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent = const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return ( return (
<Col> <Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> <Row className="justify-between gap-4 sm:flex-row">
<Row className="gap-8">
<Col> <Col>
<div className="text-sm text-gray-500">Investment value</div> <div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg"> <div className="text-lg">
{formatMoney(currentNetInvestment)}{' '} {formatMoney(currentNetInvestment)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} /> <ProfitBadge profitPercent={investedProfitPercent} />
</div> </div>
</Col> </Col>
<Col>
<div className="text-sm text-gray-500">Total profit</div>
<div className="text-lg">
{formatMoney(totalPnl)}{' '}
<ProfitBadge profitPercent={totalProfitPercent} />
</div>
</Col>
</Row>
<Row className="gap-8"> <Row className="gap-2">
<select <select
className="select select-bordered self-start" className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as BetFilter)} onChange={(e) => setFilter(e.target.value as BetFilter)}
> >
@ -195,7 +196,7 @@ export function BetsList(props: { user: User }) {
</select> </select>
<select <select
className="select select-bordered self-start" className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as BetSort)} onChange={(e) => setSort(e.target.value as BetSort)}
> >
@ -205,7 +206,7 @@ export function BetsList(props: { user: User }) {
<option value="closeTime">Close date</option> <option value="closeTime">Close date</option>
</select> </select>
</Row> </Row>
</Col> </Row>
<Col className="mt-6 divide-y"> <Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? ( {displayedContracts.length === 0 ? (
@ -346,8 +347,7 @@ function ContractBets(props: {
<BetsSummary <BetsSummary
className="mt-8 mr-5 flex-1 sm:mr-8" className="mt-8 mr-5 flex-1 sm:mr-8"
contract={contract} contract={contract}
bets={bets} userBets={bets}
isYourBets={isYourBets}
/> />
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
@ -373,125 +373,6 @@ function ContractBets(props: {
) )
} }
export function BetsSummary(props: {
contract: Contract
bets: Bet[]
isYourBets: boolean
className?: string
}) {
const { contract, isYourBets, className } = props
const { resolution, closeTime, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isCpmm = mechanism === 'cpmm-1'
const isClosed = closeTime && Date.now() > closeTime
const bets = props.bets.filter((b) => !b.isAnte)
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const [showSellModal, setShowSellModal] = useState(false)
const user = useUser()
const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0)
? floatingEqual(totalShares.NO ?? 0, 0)
? undefined
: 'NO'
: 'YES'
const canSell =
isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user
return (
<Col className={clsx(className, 'gap-4')}>
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
{canSell && (
<>
<button
className="btn btn-sm self-end"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</>
)}
</Row>
<Row className="flex-wrap-none gap-4">
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : isBinary ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel />
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <NoLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expected value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
</Row>
</Col>
)
}
export function ContractBetsTable(props: { export function ContractBetsTable(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
@ -610,18 +491,24 @@ function BetRow(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const saleAmount = saleBet?.sale?.amount // calculateSaleAmount is very slow right now so that's why we memoized this
const payout = useMemo(() => {
const saleBetAmount = saleBet?.sale?.amount
if (saleBetAmount) {
return saleBetAmount
} else if (contract.isResolved) {
return resolvedPayout(contract, bet)
} else {
return calculateSaleAmount(contract, bet, unfilledBets)
}
}, [contract, bet, saleBet, unfilledBets])
const saleDisplay = isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
) : saleAmount !== undefined ? ( ) : saleBet ? (
<>{formatMoney(saleAmount)} (sold)</> <>{formatMoney(payout)} (sold)</>
) : ( ) : (
formatMoney( formatMoney(payout)
isResolved
? resolvedPayout(contract, bet)
: calculateSaleAmount(contract, bet, unfilledBets)
)
) )
const payoutIfChosenDisplay = const payoutIfChosenDisplay =
@ -722,8 +609,8 @@ function SellButton(props: {
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell', label: 'Sell',
disabled: isSubmitting,
}} }}
submitBtn={{ className: 'btn-primary', label: 'Sell' }} submitBtn={{ className: 'btn-primary', label: 'Sell' }}
onSubmit={async () => { onSubmit={async () => {
@ -753,30 +640,3 @@ function SellButton(props: {
</ConfirmationButton> </ConfirmationButton>
) )
} }
export function ProfitBadge(props: {
profitPercent: number
round?: boolean
className?: string
}) {
const { profitPercent, round, className } = props
if (!profitPercent) return null
const colors =
profitPercent > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
return (
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors,
className
)}
>
{(profitPercent > 0 ? '+' : '') +
profitPercent.toFixed(round ? 0 : 1) +
'%'}
</span>
)
}

View File

@ -46,20 +46,26 @@ export function Button(props: {
<button <button
type={type} type={type}
className={clsx( className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50', 'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses, sizeClasses,
color === 'green' && 'btn-primary text-white', color === 'green' &&
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', 'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'red' &&
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', 'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'yellow' &&
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', 'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' && color === 'gradient' &&
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', 'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' && color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none', 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -0,0 +1,86 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { getProbability, getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
formatPct,
} from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime),
y: b.probAfter,
obj: b,
}))
}
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatPct(data.y)}</span>
</Row>
)
}
export const BinaryContractChart = (props: {
contract: BinaryContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const startP = getInitialProbability(contract)
const endP = getProbability(contract)
const betPoints = useMemo(() => getBetPoints(bets), [bets])
const data = useMemo(() => {
return [
{ x: new Date(start), y: startP },
...betPoints,
{ x: new Date(end ?? Date.now() + DAY_MS), y: endP },
]
}, [start, startP, end, endP, betPoints])
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
return (
<SingleValueHistoryChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
color="#11b981"
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip}
pct
/>
)
}

View File

@ -0,0 +1,224 @@
import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate'
import { DAY_MS } from 'common/util/time'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatPct,
formatDateInRange,
} from '../helpers'
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
const CATEGORY_COLORS = [
'#00b8dd',
'#eecafe',
'#874c62',
'#6457ca',
'#f773ba',
'#9c6bbc',
'#a87744',
'#af8a04',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
]
const getTrackedAnswers = (
contract: FreeResponseContract | MultipleChoiceContract,
topN: number
) => {
const { answers, outcomeType, totalBets } = contract
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
)
})
return sortBy(
validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN)
}
const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
)
const points: MultiPoint<Bet>[] = []
for (const bet of sortedBets) {
const { outcome, shares } = bet
sharesByOutcome[outcome] += shares
const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2)
)
points.push({
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
obj: bet,
})
}
return points
}
type LegendItem = { color: string; label: string; value?: string }
const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
<ol className={className}>
{items.map((item) => (
<li key={item.label} className="flex flex-row justify-between gap-4">
<Row className="items-center gap-2 overflow-hidden">
<span
className="h-4 w-4 shrink-0"
style={{ backgroundColor: item.color }}
></span>
<span className="text-semibold overflow-hidden text-ellipsis">
{item.label}
</span>
</Row>
<span className="text-greyscale-6">{item.value}</span>
</li>
))}
</ol>
)
}
export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: MultiPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract)
const answers = useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo(
() => [
{ x: new Date(start), y: answers.map((_) => 0) },
...betPoints,
{
x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
},
],
[answers, contract, betPoints, start, end]
)
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
const ChoiceTooltip = useMemo(
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const legendItems = sortBy(
data.y.map((p, i) => ({
color: CATEGORY_COLORS[i],
label: answers[i].text,
value: formatPct(p),
p,
})),
(item) => -item.p
).slice(0, 10)
return (
<>
<Row className="items-center gap-2">
{data.obj && (
<Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
)}
<span className="text-semibold text-base">
{formatDateInRange(d, start, end)}
</span>
</Row>
<Legend className="max-w-xs" items={legendItems} />
</>
)
},
[answers]
)
return (
<MultiValueHistoryChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
colors={CATEGORY_COLORS}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip}
pct
/>
)
}

View File

@ -0,0 +1,35 @@
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { BinaryContractChart } from './binary'
import { PseudoNumericContractChart } from './pseudo-numeric'
import { ChoiceContractChart } from './choice'
import { NumericContractChart } from './numeric'
export const ContractChart = (props: {
contract: Contract
bets: Bet[]
width: number
height: number
}) => {
const { contract } = props
switch (contract.outcomeType) {
case 'BINARY':
return <BinaryContractChart {...{ ...props, contract }} />
case 'PSEUDO_NUMERIC':
return <PseudoNumericContractChart {...{ ...props, contract }} />
case 'FREE_RESPONSE':
case 'MULTIPLE_CHOICE':
return <ChoiceContractChart {...{ ...props, contract }} />
case 'NUMERIC':
return <NumericContractChart {...{ ...props, contract }} />
default:
return null
}
}
export {
BinaryContractChart,
PseudoNumericContractChart,
ChoiceContractChart,
NumericContractChart,
}

View File

@ -0,0 +1,59 @@
import { useMemo } from 'react'
import { range } from 'lodash'
import { scaleLinear } from 'd3-scale'
import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts'
const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract
const step = (max - min) / bucketCount
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
return range(bucketCount).map((i) => ({
x: min + step * (i + 0.5),
y: bucketProbs[`${i}`],
}))
}
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return (
<>
<span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</>
)
}
export const NumericContractChart = (props: {
contract: NumericContract
width: number
height: number
onMouseOver?: (p: DistributionPoint | undefined) => void
}) => {
const { contract, width, height, onMouseOver } = props
const { min, max } = contract
const data = useMemo(() => getNumericChartData(contract), [contract])
const maxY = Math.max(...data.map((d) => d.y))
const xScale = scaleLinear([min, max], [0, width - MARGIN_X])
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
return (
<DistributionChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
color={NUMERIC_GRAPH_COLOR}
onMouseOver={onMouseOver}
Tooltip={NumericChartTooltip}
/>
)
}

View File

@ -0,0 +1,107 @@
import { useMemo } from 'react'
import { last, sortBy } from 'lodash'
import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape'
import { Bet } from 'common/bet'
import { DAY_MS } from 'common/util/time'
import { getInitialProbability, getProbability } from 'common/calculate'
import { formatLargeNumber } from 'common/util/format'
import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import {
TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange,
getRightmostVisibleDate,
formatDateInRange,
} from '../helpers'
import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
// mqp: note that we have an idiosyncratic version of 'log scale'
// contracts. the values are stored "linearly" and can include zero.
// as a result, we have to do some weird-looking stuff in this code
const getScaleP = (min: number, max: number, isLogScale: boolean) => {
return (p: number) =>
isLogScale
? 10 ** (p * Math.log10(max - min + 1)) + min - 1
: p * (max - min) + min
}
const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime),
y: scaleP(b.probAfter),
obj: b,
}))
}
const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>>
) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row>
)
}
export const PseudoNumericContractChart = (props: {
contract: PseudoNumericContract
bets: Bet[]
width: number
height: number
onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void
}) => {
const { contract, bets, width, height, onMouseOver } = props
const { min, max, isLogScale } = contract
const [start, end] = getDateRange(contract)
const scaleP = useMemo(
() => getScaleP(min, max, isLogScale),
[min, max, isLogScale]
)
const startP = scaleP(getInitialProbability(contract))
const endP = scaleP(getProbability(contract))
const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP])
const data = useMemo(
() => [
{ x: new Date(start), y: startP },
...betPoints,
{ x: new Date(end ?? Date.now() + DAY_MS), y: endP },
],
[betPoints, start, startP, end, endP]
)
const rightmostDate = getRightmostVisibleDate(
end,
last(betPoints)?.x?.getTime(),
Date.now()
)
const visibleRange = [start, rightmostDate]
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
// clamp log scale to make sure zeroes go to the bottom
const yScale = isLogScale
? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true)
: scaleLinear([min, max], [height - MARGIN_Y, 0])
return (
<SingleValueHistoryChart
w={width}
h={height}
xScale={xScale}
yScale={yScale}
data={data}
curve={curveStepAfter}
onMouseOver={onMouseOver}
Tooltip={PseudoNumericChartTooltip}
color={NUMERIC_GRAPH_COLOR}
/>
)
}

View File

@ -0,0 +1,256 @@
import { useCallback, useMemo, useState } from 'react'
import { bisector } from 'd3-array'
import { axisBottom, axisLeft } from 'd3-axis'
import { D3BrushEvent } from 'd3-brush'
import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
import {
CurveFactory,
SeriesPoint,
curveLinear,
stack,
stackOrderReverse,
} from 'd3-shape'
import { range } from 'lodash'
import {
ContinuousScale,
SVGChart,
AreaPath,
AreaWithTopStroke,
Point,
TooltipComponent,
formatPct,
} from './helpers'
import { useEvent } from 'web/hooks/use-event'
export type MultiPoint<T = unknown> = Point<Date, number[], T>
export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T>
const getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
}
const betAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1]
const result = item ? { ...item, x: posX } : undefined
return result
}
}
export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[]
w: number
h: number
color: string
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => {
const { color, data, yScale, w, h, curve, Tooltip } = props
const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100)
const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2))
return { xAxis, yAxis }
}, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
</SVGChart>
)
}
export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
data: P[]
w: number
h: number
colors: readonly string[]
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { colors, data, yScale, w, h, curve, Tooltip, pct } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
type SP = SeriesPoint<P>
const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const series = useMemo(() => {
const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, o) => y[o])
.order(stackOrderReverse)
return d3Stack(data)
}, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
{series.map((s, i) => (
<AreaPath
key={i}
data={s}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
fill={colors[i]}
/>
))}
</SVGChart>
)
}
export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[]
w: number
h: number
color: string
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { color, data, yScale, w, h, curve, Tooltip, pct } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: axisLeft<number>(yScale)
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
xAxis={xAxis}
yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
</SVGChart>
)
}

View File

@ -0,0 +1,359 @@
import {
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush'
import { area, line, CurveFactory } from 'd3-shape'
import { nanoid } from 'nanoid'
import dayjs from 'dayjs'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
export interface ContinuousScale<T> extends AxisScale<T> {
invert(n: number): T
}
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
export const MARGIN_X = MARGIN.right + MARGIN.left
export const MARGIN_Y = MARGIN.top + MARGIN.bottom
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px`
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})`
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
const { h, axis } = props
const axisRef = useRef<SVGGElement>(null)
useEffect(() => {
if (axisRef.current != null) {
select(axisRef.current)
.transition()
.duration(250)
.call(axis)
.select('.domain')
.attr('stroke-width', 0)
}
}, [h, axis])
return <g ref={axisRef} transform={`translate(0, ${h})`} />
}
export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
const { w, h, axis } = props
const axisRef = useRef<SVGGElement>(null)
useEffect(() => {
if (axisRef.current != null) {
select(axisRef.current)
.transition()
.duration(250)
.call(axis)
.call((g) =>
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
)
.select('.domain')
.attr('stroke-width', 0)
}
}, [w, h, axis])
return <g ref={axisRef} />
}
const LinePathInternal = <P,>(
props: {
data: P[]
px: number | ((p: P) => number)
py: number | ((p: P) => number)
curve: CurveFactory
} & SVGProps<SVGPathElement>
) => {
const { data, px, py, curve, ...rest } = props
const d3Line = line<P>(px, py).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} fill="none" d={d3Line(data)!} />
}
export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
const AreaPathInternal = <P,>(
props: {
data: P[]
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve: CurveFactory
} & SVGProps<SVGPathElement>
) => {
const { data, px, py0, py1, curve, ...rest } = props
const d3Area = area<P>(px, py0, py1).curve(curve)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return <path {...rest} d={d3Area(data)!} />
}
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
export const AreaWithTopStroke = <P,>(props: {
color: string
data: P[]
px: number | ((p: P) => number)
py0: number | ((p: P) => number)
py1: number | ((p: P) => number)
curve: CurveFactory
}) => {
const { color, data, px, py0, py1, curve } = props
return (
<g>
<AreaPath
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve}
fill={color}
opacity={0.2}
/>
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
</g>
)
}
export const SVGChart = <X, TT>(props: {
children: ReactNode
w: number
h: number
xAxis: Axis<X>
yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<X, TT>
}) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y
const clipPathId = useMemo(() => nanoid(), [])
const justSelected = useRef(false)
useEffect(() => {
if (onSelect != null && overlayRef.current) {
const brush = brushX().extent([
[0, 0],
[innerW, innerH],
])
brush.on('end', (ev) => {
// when we clear the brush after a selection, that would normally cause
// another 'end' event, so we have to suppress it with this flag
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouse(undefined)
if (overlayRef.current) {
select(overlayRef.current).call(brush.clear)
}
} else {
justSelected.current = false
}
})
// mqp: shape-rendering null overrides the default d3-brush shape-rendering
// of `crisp-edges`, which seems to cause graphical glitches on Chrome
// (i.e. the bug where the area fill flickers white)
select(overlayRef.current)
.call(brush)
.select('.selection')
.attr('shape-rendering', 'null')
}
}, [innerW, innerH, onSelect])
const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouse(undefined)
}
}
}
const onPointerLeave = () => {
setMouse(undefined)
}
return (
<div className="relative overflow-hidden">
{mouse && Tooltip && (
<TooltipContainer
setElem={tooltipMeasure.setElem}
pos={getTooltipPosition(
mouse.x,
mouse.y,
innerW,
innerH,
tooltipMeasure.width,
tooltipMeasure.height
)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer>
)}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<clipPath id={clipPathId}>
<rect x={0} y={0} width={innerW} height={innerH} />
</clipPath>
<g transform={MARGIN_XFORM}>
<XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g>
<g
ref={overlayRef}
x="0"
y="0"
width={innerW}
height={innerH}
fill="none"
pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
</g>
</svg>
</div>
)
}
export type TooltipPosition = { left: number; bottom: number }
export const getTooltipPosition = (
mouseX: number,
mouseY: number,
containerWidth: number,
containerHeight: number,
tooltipWidth?: number,
tooltipHeight?: number
) => {
let left = mouseX + 12
let bottom = containerHeight - mouseY + 12
if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) {
left -= overflow
}
}
if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY
if (overflow > 0) {
bottom -= overflow
}
}
return { left, bottom }
}
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void
pos: TooltipPosition
className?: string
children: React.ReactNode
}) => {
const { setElem, pos, className, children } = props
return (
<div
ref={setElem}
className={clsx(
className,
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
)}
style={{ margin: MARGIN_STYLE, ...pos }}
>
{children}
</div>
)
}
export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract
const isClosed = !!closeTime && Date.now() > closeTime
const endDate = resolutionTime ?? (isClosed ? closeTime : null)
return [createdTime, endDate ?? null] as const
}
export const getRightmostVisibleDate = (
contractEnd: number | null | undefined,
lastActivity: number | null | undefined,
now: number
) => {
if (contractEnd != null) {
return contractEnd
} else if (lastActivity != null) {
// client-DB clock divergence may cause last activity to be later than now
return Math.max(lastActivity, now)
} else {
return now
}
}
export const formatPct = (n: number, digits?: number) => {
return `${(n * 100).toFixed(digits ?? 0)}%`
}
export const formatDate = (
date: Date,
opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean }
) => {
const { includeYear, includeHour, includeMinute } = opts
const d = dayjs(date)
const now = Date.now()
if (
d.add(1, 'minute').isAfter(now) &&
d.subtract(1, 'minute').isBefore(now)
) {
return 'Now'
} else {
const dayName = d.isSame(now, 'day')
? 'Today'
: d.add(1, 'day').isSame(now, 'day')
? 'Yesterday'
: null
let format = dayName ? `[${dayName}]` : 'MMM D'
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}
}
export const formatDateInRange = (d: Date, start: Date, end: Date) => {
const opts = {
includeYear: !dayjs(start).isSame(end, 'year'),
includeHour: dayjs(start).add(8, 'day').isAfter(end),
includeMinute: dayjs(end).diff(start, 'hours') < 2,
}
return formatDate(d, opts)
}

View File

@ -0,0 +1,76 @@
import { useMemo } from 'react'
import { scaleTime, scaleLinear } from 'd3-scale'
import { min, max } from 'lodash'
import dayjs from 'dayjs'
import { formatPercent } from 'common/util/format'
import { Row } from '../layout/row'
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers'
import { SizedContainer } from 'web/components/sized-container'
const getPoints = (startDate: number, dailyValues: number[]) => {
const startDateDayJs = dayjs(startDate)
return dailyValues.map((y, i) => ({
x: startDateDayJs.add(i, 'day').toDate(),
y: y,
}))
}
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{data.y}</span>
</Row>
)
}
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
<span className="text-greyscale-6">{formatPercent(data.y)}</span>
</Row>
)
}
export function DailyChart(props: {
startDate: number
dailyValues: number[]
excludeFirstDays?: number
pct?: boolean
}) {
const { dailyValues, startDate, excludeFirstDays, pct } = props
const data = useMemo(
() => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0),
[startDate, dailyValues, excludeFirstDays]
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minDate = min(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxDate = max(data.map((d) => d.x))!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const maxValue = max(data.map((d) => d.y))!
return (
<SizedContainer fullHeight={250} mobileHeight={250}>
{(width, height) => (
<SingleValueHistoryChart
w={width}
h={height}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
data={data}
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
color="#11b981"
pct={pct}
/>
)}
</SizedContainer>
)
}

View File

@ -11,7 +11,7 @@ import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: { export function CommentInput(props: {
replyToUser?: { id: string; username: string } replyTo?: { id: string; username: string }
// Reply to a free response answer // Reply to a free response answer
parentAnswerOutcome?: string parentAnswerOutcome?: string
// Reply to another comment // Reply to another comment
@ -19,7 +19,7 @@ export function CommentInput(props: {
onSubmitComment?: (editor: Editor) => void onSubmitComment?: (editor: Editor) => void
className?: string className?: string
}) { }) {
const { parentAnswerOutcome, parentCommentId, replyToUser, onSubmitComment } = const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
props props
const user = useUser() const user = useUser()
@ -55,7 +55,7 @@ export function CommentInput(props: {
<CommentInputTextArea <CommentInputTextArea
editor={editor} editor={editor}
upload={upload} upload={upload}
replyToUser={replyToUser} replyTo={replyTo}
user={user} user={user}
submitComment={submitComment} submitComment={submitComment}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
@ -67,14 +67,13 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: { export function CommentInputTextArea(props: {
user: User | undefined | null user: User | undefined | null
replyToUser?: { id: string; username: string } replyTo?: { id: string; username: string }
editor: Editor | null editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload'] upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: () => void submitComment: () => void
isSubmitting: boolean isSubmitting: boolean
}) { }) {
const { user, editor, upload, submitComment, isSubmitting, replyToUser } = const { user, editor, upload, submitComment, isSubmitting, replyTo } = props
props
useEffect(() => { useEffect(() => {
editor?.setEditable(!isSubmitting) editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor]) }, [isSubmitting, editor])
@ -108,12 +107,12 @@ export function CommentInputTextArea(props: {
}, },
}) })
// insert at mention and focus // insert at mention and focus
if (replyToUser) { if (replyTo) {
editor editor
.chain() .chain()
.setContent({ .setContent({
type: 'mention', type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id }, attrs: { label: replyTo.username, id: replyTo.id },
}) })
.insertContent(' ') .insertContent(' ')
.focus() .focus()
@ -127,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}> <TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" className="px-2 text-gray-400 hover:text-gray-500 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty} disabled={!editor || editor.isEmpty}
onClick={submit} onClick={submit}
> >

View File

@ -1,5 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { Button, ColorType, SizeType } from './button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -9,6 +10,9 @@ export function ConfirmationButton(props: {
label: string label: string
icon?: JSX.Element icon?: JSX.Element
className?: string className?: string
color?: ColorType
size?: SizeType
disabled?: boolean
} }
cancelBtn?: { cancelBtn?: {
label?: string label?: string
@ -68,13 +72,16 @@ export function ConfirmationButton(props: {
</Row> </Row>
</Col> </Col>
</Modal> </Modal>
<div <Button
className={clsx('btn', openModalBtn.className)} className={clsx(openModalBtn.className)}
onClick={() => updateOpen(true)} onClick={() => updateOpen(true)}
disabled={openModalBtn.disabled}
color={openModalBtn.color}
size={openModalBtn.size}
> >
{openModalBtn.icon} {openModalBtn.icon}
{openModalBtn.label} {openModalBtn.label}
</div> </Button>
</> </>
) )
} }
@ -84,18 +91,25 @@ export function ResolveConfirmationButton(props: {
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClass?: string submitButtonClass?: string
color?: ColorType
disabled?: boolean
}) { }) {
const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } = const {
props onResolve,
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
className: clsx( className: clsx('border-none self-start', openModalButtonClass),
'border-none self-start',
openModalButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: 'Resolve', label: 'Resolve',
color: color,
disabled: isSubmitting || disabled,
size: 'xl',
}} }}
cancelBtn={{ cancelBtn={{
label: 'Back', label: 'Back',

View File

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

View File

@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true, noLinkAvatar: true,
}} }}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), itemIds: contracts.map((c) => c.id),
highlightClassName: highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}

View File

@ -0,0 +1,74 @@
import { Contract } from 'common/contract'
import { useUser } from 'web/hooks/use-user'
import { useState } from 'react'
import { addCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button'
export function AddCommentBountyPanel(props: { contract: Contract }) {
const { contract } = props
const { id: contractId, slug } = contract
const user = useUser()
const amount = COMMENT_BOUNTY_AMOUNT
const totalAdded = contract.openCommentBounties ?? 0
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const submit = () => {
if ((user?.balance ?? 0) < amount) {
setError('Insufficient balance')
return
}
setIsLoading(true)
setIsSuccess(false)
addCommentBounty({ amount, contractId })
.then((_) => {
track('offer comment bounty', {
amount,
contractId,
})
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('add comment bounty', { amount, contractId, slug })
}
return (
<>
<div className="mb-4 text-gray-500">
Add a {formatMoney(amount)} bounty for good comments that the creator
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
<Row className={'items-center gap-2'}>
<Button
className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}

View File

@ -0,0 +1,47 @@
import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
export function BountiedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span>
)
}
export function BountiedContractSmallBadge(props: {
contract: Contract
showAmount?: boolean
}) {
const { contract, showAmount } = props
const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return (
<Tooltip
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
)
}
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties
)} currently available.`

View File

@ -7,6 +7,7 @@ import { Col } from '../layout/col'
import { import {
BinaryContract, BinaryContract,
Contract, Contract,
CPMMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
NumericContract, NumericContract,
@ -32,6 +33,8 @@ import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip' import { Tooltip } from '../tooltip'
import { SiteLink } from '../site-link'
import { ProbChange } from './prob-change-table'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -43,6 +46,7 @@ export function ContractCard(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
trackingPostfix?: string trackingPostfix?: string
noLinkAvatar?: boolean noLinkAvatar?: boolean
newTab?: boolean
}) { }) {
const { const {
showTime, showTime,
@ -53,6 +57,7 @@ export function ContractCard(props: {
hideGroupLink, hideGroupLink,
trackingPostfix, trackingPostfix,
noLinkAvatar, noLinkAvatar,
newTab,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -186,6 +191,7 @@ export function ContractCard(props: {
} }
)} )}
className="absolute top-0 left-0 right-0 bottom-0" className="absolute top-0 left-0 right-0 bottom-0"
target={newTab ? '_blank' : '_self'}
/> />
</Link> </Link>
)} )}
@ -208,7 +214,9 @@ export function BinaryResolutionOrChance(props: {
const probChanged = before !== after const probChanged = before !== after
return ( return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> <Col
className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)}
>
{resolution ? ( {resolution ? (
<> <>
<div <div
@ -379,3 +387,34 @@ export function PseudoNumericResolutionOrExpectation(props: {
</Col> </Col>
) )
} }
export function ContractCardProbChange(props: {
contract: CPMMBinaryContract
noLinkAvatar?: boolean
className?: string
}) {
const { contract, noLinkAvatar, className } = props
return (
<Col
className={clsx(
className,
'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
)}
>
<AvatarDetails
contract={contract}
className={'px-6 pt-4'}
noLink={noLinkAvatar}
/>
<Row className={clsx('items-start justify-between gap-4 ', className)}>
<SiteLink
className="pl-6 pr-0 pt-2 pb-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-3">{contract.question}</span>
</SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} />
</Row>
</Col>
)
}

View File

@ -32,6 +32,10 @@ import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle' import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
import {
BountiedContractBadge,
BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -63,6 +67,8 @@ export function MiscDetails(props: {
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : ( ) : (
@ -126,9 +132,10 @@ export function ContractDetails(props: {
</Row> </Row>
{/* GROUPS */} {/* GROUPS */}
{isMobile && ( {isMobile && (
<div className="mt-2"> <Row className="mt-2 gap-1">
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} /> <MarketGroups contract={contract} disabled={disabled} />
</div> </Row>
)} )}
</Col> </Col>
) )
@ -171,14 +178,18 @@ export function MarketSubheader(props: {
/> />
)} )}
</Row> </Row>
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs"> <Row className="text-2xs text-greyscale-4 flex-wrap gap-2 sm:text-xs">
<CloseOrResolveTime <CloseOrResolveTime
contract={contract} contract={contract}
resolvedDate={resolvedDate} resolvedDate={resolvedDate}
isCreator={isCreator} isCreator={isCreator}
disabled={disabled}
/> />
{!isMobile && ( {!isMobile && (
<Row className={'gap-1'}>
<BountiedContractSmallBadge contract={contract} />
<MarketGroups contract={contract} disabled={disabled} /> <MarketGroups contract={contract} disabled={disabled} />
</Row>
)} )}
</Row> </Row>
</Col> </Col>
@ -190,8 +201,9 @@ export function CloseOrResolveTime(props: {
contract: Contract contract: Contract
resolvedDate: any resolvedDate: any
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { contract, resolvedDate, isCreator } = props const { contract, resolvedDate, isCreator, disabled } = props
const { resolutionTime, closeTime } = contract const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) { if (!!closeTime || !!resolvedDate) {
return ( return (
@ -215,6 +227,7 @@ export function CloseOrResolveTime(props: {
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
isCreator={isCreator ?? false} isCreator={isCreator ?? false}
disabled={disabled}
/> />
</Row> </Row>
)} )}
@ -235,7 +248,8 @@ export function MarketGroups(props: {
return ( return (
<> <>
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} /> <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
{!disabled && user && ( {!disabled && user && (
<button <button
className="text-greyscale-4 hover:text-greyscale-3" className="text-greyscale-4 hover:text-greyscale-3"
@ -320,19 +334,34 @@ export function ExtraMobileContractDetails(props: {
) )
} }
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { export function GroupDisplay(props: {
const { groupToDisplay } = props groupToDisplay?: GroupLink | null
disabled?: boolean
}) {
const { groupToDisplay, disabled } = props
if (groupToDisplay) { if (groupToDisplay) {
return ( const groupSection = (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}> <a
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]"> className={clsx(
'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]',
!disabled && 'hover:bg-greyscale-3 cursor-pointer'
)}
>
{groupToDisplay.name} {groupToDisplay.name}
</a> </a>
)
return disabled ? (
groupSection
) : (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
{groupSection}
</Link> </Link>
) )
} else } else
return ( return (
<div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white"> <div className="bg-greyscale-4 truncate rounded-full py-0.5 px-2 text-xs text-white">
No Group No Group
</div> </div>
) )
@ -342,8 +371,9 @@ function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
isCreator: boolean isCreator: boolean
disabled?: boolean
}) { }) {
const { closeTime, contract, isCreator } = props const { closeTime, contract, isCreator, disabled } = props
const dayJsCloseTime = dayjs(closeTime) const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs() const dayJsNow = dayjs()
@ -356,18 +386,22 @@ function EditableCloseDate(props: {
closeTime && dayJsCloseTime.format('HH:mm') closeTime && dayJsCloseTime.format('HH:mm')
) )
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => { let newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
function onSave(customTime?: number) {
if (customTime) {
newCloseTime = customTime
setCloseDate(dayjs(newCloseTime).format('YYYY-MM-DD'))
setCloseHoursMinutes(dayjs(newCloseTime).format('HH:mm'))
}
if (!newCloseTime) return if (!newCloseTime) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false) if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) { else {
const content = contract.description const content = contract.description
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
@ -416,13 +450,21 @@ function EditableCloseDate(props: {
/> />
</Row> </Row>
<Button <Button
className="mt-2" className="mt-4"
size={'xs'} size={'xs'}
color={'indigo'} color={'indigo'}
onClick={onSave} onClick={() => onSave()}
> >
Done Done
</Button> </Button>
<Button
className="mt-4"
size={'xs'}
color={'gray-white'}
onClick={() => onSave(Date.now())}
>
Close Now
</Button>
</Col> </Col>
</Modal> </Modal>
<DateTimeTooltip <DateTimeTooltip
@ -430,8 +472,8 @@ function EditableCloseDate(props: {
time={closeTime} time={closeTime}
> >
<span <span
className={isCreator ? 'cursor-pointer' : ''} className={!disabled && isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)} onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
> >
{isSameDay ? ( {isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span> <span className={'capitalize'}> {fromNow(closeTime)}</span>

View File

@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel' import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Title } from '../title' import { Title } from '../title'
@ -196,9 +196,7 @@ export function ContractInfoDialog(props: {
<Row className="flex-wrap"> <Row className="flex-wrap">
<DuplicateContractButton contract={contract} /> <DuplicateContractButton contract={contract} />
</Row> </Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {!contract.resolution && <LiquidityBountyPanel contract={contract} />}
<LiquidityPanel contract={contract} />
)}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

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

View File

@ -1,8 +1,6 @@
import React from 'react'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { ContractProbGraph } from './contract-prob-graph' import { ContractChart } from 'web/components/charts/contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
@ -13,20 +11,18 @@ import {
PseudoNumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation,
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetButton from '../bet-button' import BetButton, { BinaryMobileBetting } from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph'
import { import {
Contract, Contract,
BinaryContract,
CPMMContract, CPMMContract,
CPMMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
BinaryContract,
} from 'common/contract' } from 'common/contract'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph' import { SizedContainer } from 'web/components/sized-container'
const OverviewQuestion = (props: { text: string }) => ( const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
@ -46,8 +42,29 @@ const BetWidget = (props: { contract: CPMMContract }) => {
) )
} }
const NumericOverview = (props: { contract: NumericContract }) => { const SizedContractChart = (props: {
const { contract } = props contract: Contract
bets: Bet[]
fullHeight: number
mobileHeight: number
}) => {
const { fullHeight, mobileHeight, contract, bets } = props
return (
<SizedContainer fullHeight={fullHeight} mobileHeight={mobileHeight}>
{(width, height) => (
<ContractChart
width={width}
height={height}
contract={contract}
bets={bets}
/>
)}
</SizedContainer>
)
}
const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => {
const { contract, bets } = props
return ( return (
<Col className="gap-1 md:gap-2"> <Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4"> <Col className="gap-3 px-2 sm:gap-4">
@ -64,7 +81,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
contract={contract} contract={contract}
/> />
</Col> </Col>
<NumericGraph contract={contract} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -78,20 +100,24 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} /> <OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance <BinaryResolutionOrChance
className="hidden items-end xl:flex" className="flex items-end"
contract={contract} contract={contract}
large large
/> />
</Row> </Row>
</Col>
<SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} /> <BinaryMobileBetting contract={contract} />
)} )}
</Row> </Row>
</Col> </Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
</Col>
) )
} }
@ -110,9 +136,12 @@ const ChoiceOverview = (props: {
<FreeResponseResolutionOrChance contract={contract} truncate="none" /> <FreeResponseResolutionOrChance contract={contract} truncate="none" />
)} )}
</Col> </Col>
<Col className={'mb-1 gap-y-2'}> <SizedContractChart
<AnswersGraph contract={contract} bets={[...bets].reverse()} /> contract={contract}
</Col> bets={bets}
fullHeight={350}
mobileHeight={250}
/>
</Col> </Col>
) )
} }
@ -138,7 +167,12 @@ const PseudoNumericOverview = (props: {
{tradingAllowed(contract) && <BetWidget contract={contract} />} {tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row> </Row>
</Col> </Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} /> <SizedContractChart
contract={contract}
bets={bets}
fullHeight={250}
mobileHeight={150}
/>
</Col> </Col>
) )
} }
@ -152,7 +186,7 @@ export const ContractOverview = (props: {
case 'BINARY': case 'BINARY':
return <BinaryOverview contract={contract} bets={bets} /> return <BinaryOverview contract={contract} bets={bets} />
case 'NUMERIC': case 'NUMERIC':
return <NumericOverview contract={contract} /> return <NumericOverview contract={contract} bets={bets} />
case 'PSEUDO_NUMERIC': case 'PSEUDO_NUMERIC':
return <PseudoNumericOverview contract={contract} bets={bets} /> return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE': case 'FREE_RESPONSE':

View File

@ -1,203 +0,0 @@
import { DatumValue } from '@nivo/core'
import { ResponsiveLine, SliceTooltipProps } from '@nivo/line'
import { BasicTooltip } from '@nivo/tooltip'
import dayjs from 'dayjs'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size'
import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: {
contract: BinaryContract | PseudoNumericContract
bets: Bet[]
height?: number
}) {
const { contract, height } = props
const { resolutionTime, closeTime, outcomeType } = contract
const now = Date.now()
const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
const startProb = getInitialProbability(contract)
const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
const f: (p: number) => number = isBinary
? (p) => p
: isLogScale
? (p) => p * Math.log10(contract.max - contract.min + 1)
: (p) => p * (contract.max - contract.min) + contract.min
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? now
)
// Add a fake datapoint so the line continues to the right
times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1])
const quartiles = [0, 25, 50, 75, 100]
const yTickValues = isBinary
? quartiles
: quartiles.map((x) => x / 100).map(f)
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
const startDate = dayjs(times[0])
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
// Minimum number of points for the graph to have. For smooth tooltip movement
// If we aren't actually loading any data yet, skip adding extra points to let page load faster
// This fn runs again once DOM is finished loading
const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1
for (let i = 0; i < times.length - 1; i++) {
const p = probs[i]
const d0 = times[i]
const d1 = times[i + 1]
const msDiff = d1 - d0
const numPoints = Math.floor(msDiff / timeStep)
points.push({ x: new Date(times[i]), y: s * p })
if (numPoints > 1) {
const thisTimeStep: number = msDiff / numPoints
for (let n = 1; n < numPoints; n++) {
points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
}
}
}
const data = [
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
]
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
const formatter = isBinary
? formatPercent
: isLogScale
? (x: DatumValue) =>
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return (
<div
className="w-full overflow-visible"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={
isBinary
? { min: 0, max: 100, type: 'linear' }
: isLogScale
? {
min: 0,
max: Math.log10(contract.max - contract.min + 1),
type: 'linear',
}
: { min: contract.min, max: contract.max, type: 'linear' }
}
yFormat={formatter}
gridYValues={yTickValues}
axisLeft={{
tickValues: yTickValues,
format: formatter,
}}
xScale={{
type: 'time',
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ datum: 'color' }}
curve="stepAfter"
enablePoints={false}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
animate={false}
sliceTooltip={SliceTooltip}
/>
</div>
)
})
const SliceTooltip = ({ slice }: SliceTooltipProps) => {
return (
<BasicTooltip
id={slice.points.map((point) => [
<span key="date">
<strong>{point.data[`yFormatted`]}</strong> {point.data['xFormatted']}
</span>,
])}
/>
)
}
function formatPercent(y: DatumValue) {
return `${Math.round(+y.toString())}%`
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'
}
if (includeMinute) {
format += ', h:mma'
} else if (includeHour) {
format += ', ha'
} else if (includeYear) {
format += ', YYYY'
}
return d.format(format)
}

View File

@ -1,16 +1,19 @@
import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractComment } from 'common/comment' import { PAST_BETS } from 'common/user'
import { PAST_BETS, User } from 'common/user' import { ContractBetsTable } from '../bets-list'
import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
@ -19,27 +22,194 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip'
import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge'
import { Row } from '../layout/row'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
user: User | null | undefined
bets: Bet[] bets: Bet[]
userBets: Bet[]
comments: ContractComment[] comments: ContractComment[]
}) { }) {
const { contract, user, bets } = props const { contract, bets, userBets, comments } = props
const { outcomeType } = contract
const isMobile = useIsMobile()
const yourTrades = (
<div>
<Spacer h={6} />
<ContractBetsTable contract={contract} bets={userBets} isYourBets />
<Spacer h={12} />
</div>
)
const tabs = buildArray(
{
title: 'Comments',
content: <CommentsTabContent contract={contract} comments={comments} />,
},
bets.length > 0 && {
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
userBets.length > 0 && {
title: 'Your trades',
content: yourTrades,
}
)
return (
<Tabs className="mb-4" currentPageForAnalytics={'contract'} tabs={tabs} />
)
}
const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract
comments: ContractComment[]
}) {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const lps = useLiquidity(contract.id) const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = useState<'Newest' | 'Best'>('Newest')
const me = useUser()
const userBets = if (comments == null) {
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) return <LoadingIndicator />
}
const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
const sortedComments = sortBy(comments, (c) =>
sort === 'Newest'
? c.createdTime
: // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
tipsOrBountiesAwarded &&
c.createdTime > Date.now() - 10 * MINUTE_MS &&
c.userId === me?.id
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
)
const commentsByParent = groupBy(
sortedComments,
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? []
// Top level comments are reverse-chronological, while replies are chronological
if (sort === 'Newest') topLevelComments.reverse()
if (contract.outcomeType === 'FREE_RESPONSE') {
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
return (
<>
{sortedAnswers.map((answer) => (
<div key={answer.id} className="relative pb-4">
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentInput className="mb-5" contract={contract} />
{generalTopLevelComments.map((comment) => (
<FeedCommentThread
key={comment.id}
contract={contract}
parentComment={comment}
threadComments={commentsByParent[comment.id] ?? []}
tips={tips}
/>
))}
</Col>
</>
)
} else {
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{comments.length > 0 && (
<Row className="mb-4 items-center">
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={
sort === 'Best'
? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: ''
}
>
Sort by: {sort}
</Tooltip>
</Button>
<BountiedContractSmallBadge contract={contract} showAmount />
</Row>
)}
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
})
const BetsTabContent = memo(function BetsTabContent(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? []
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const visibleLps = (lps ?? []).filter( const visibleLps = lps.filter(
(l) => (l) =>
!l.isAnte && !l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID && l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
@ -47,77 +217,47 @@ export function ContractTabs(props: {
l.amount > 0 l.amount > 0
) )
const comments = useComments(contract.id) ?? props.comments const items = [
...visibleBets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...visibleLps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const betActivity = lps != null && ( const pageItems = sortBy(items, (item) =>
<ContractBetsActivity item.type === 'bet'
contract={contract} ? -item.bet.createdTime
bets={visibleBets} : item.type === 'liquidity'
lps={visibleLps} ? -item.lp.createdTime
/> : undefined
) ).slice(start, end)
const generalComments = comments.filter(
(comment) =>
comment.answerOutcome === undefined &&
(outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true)
)
const commentActivity =
outcomeType === 'FREE_RESPONSE' ? (
<>
<FreeResponseContractCommentsActivity
contract={contract}
comments={comments}
tips={tips}
/>
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentsActivity
contract={contract}
comments={generalComments}
tips={tips}
/>
</Col>
</>
) : (
<ContractCommentsActivity
contract={contract}
comments={comments}
tips={tips}
/>
)
const yourTrades = (
<div>
<BetsSummary
className="px-2"
contract={contract}
bets={userBets ?? []}
isYourBets
/>
<Spacer h={6} />
<ContractBetsTable contract={contract} bets={userBets ?? []} isYourBets />
<Spacer h={12} />
</div>
)
return ( return (
<Tabs <>
currentPageForAnalytics={'contract'} <Col className="mb-4 gap-4">
tabs={[ {pageItems.map((item) =>
{ title: 'Comments', content: commentActivity }, item.type === 'bet' ? (
{ title: capitalize(PAST_BETS), content: betActivity }, <FeedBet key={item.id} contract={contract} bet={item.bet} />
...(!user || !userBets?.length ) : (
? [] <FeedLiquidity key={item.id} liquidity={item.lp} />
: [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]}
/>
) )
} )}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
})

View File

@ -2,7 +2,7 @@ import { Contract } from 'web/lib/firebase/contracts'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card' import { ContractCard, ContractCardProbChange } from './contract-card'
import { ShowTime } from './contract-details' import { ShowTime } from './contract-details'
import { ContractSearch } from '../contract-search' import { ContractSearch } from '../contract-search'
import { useCallback } from 'react' import { useCallback } from 'react'
@ -10,9 +10,10 @@ import clsx from 'clsx'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { VisibilityObserver } from '../visibility-observer' import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css' import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract'
export type ContractHighlightOptions = { export type CardHighlightOptions = {
contractIds?: string[] itemIds?: string[]
highlightClassName?: string highlightClassName?: string
} }
@ -25,8 +26,9 @@ export function ContractsGrid(props: {
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
noLinkAvatar?: boolean noLinkAvatar?: boolean
showProbChange?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
trackingPostfix?: string trackingPostfix?: string
breakpointColumns?: { [key: string]: number } breakpointColumns?: { [key: string]: number }
}) { }) {
@ -39,8 +41,9 @@ export function ContractsGrid(props: {
highlightOptions, highlightOptions,
trackingPostfix, trackingPostfix,
} = props } = props
const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {} const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
const { contractIds, highlightClassName } = highlightOptions || {} cardUIOptions || {}
const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback( const onVisibilityUpdated = useCallback(
(visible) => { (visible) => {
if (visible && loadMore) { if (visible && loadMore) {
@ -73,7 +76,13 @@ export function ContractsGrid(props: {
className="-ml-4 flex w-auto" className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding" columnClassName="pl-4 bg-clip-padding"
> >
{contracts.map((contract) => ( {contracts.map((contract) =>
showProbChange && contract.mechanism === 'cpmm-1' ? (
<ContractCardProbChange
key={contract.id}
contract={contract as CPMMBinaryContract}
/>
) : (
<ContractCard <ContractCard
contract={contract} contract={contract}
key={contract.id} key={contract.id}
@ -90,7 +99,8 @@ export function ContractsGrid(props: {
contractIds?.includes(contract.id) && highlightClassName contractIds?.includes(contract.id) && highlightClassName
)} )}
/> />
))} )
)}
</Masonry> </Masonry>
{loadMore && ( {loadMore && (
<VisibilityObserver <VisibilityObserver
@ -118,6 +128,7 @@ export function CreatorContractsList(props: {
creatorId: creator.id, creatorId: creator.id,
}} }}
persistPrefix={`user-${creator.id}`} persistPrefix={`user-${creator.id}`}
profile={true}
/> />
) )
} }

View File

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

View File

@ -1,6 +1,4 @@
import { HeartIcon } from '@heroicons/react/outline' import React, { useMemo, useState } from 'react'
import { Button } from 'web/components/button'
import React, { useMemo } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useUserLikes } from 'web/hooks/use-likes' import { useUserLikes } from 'web/hooks/use-likes'
@ -8,66 +6,51 @@ import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { likeContract } from 'web/lib/firebase/likes' import { likeContract } from 'web/lib/firebase/likes'
import { LIKE_TIP_AMOUNT } from 'common/like' import { LIKE_TIP_AMOUNT } from 'common/like'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash' import { sum } from 'lodash'
import { TipButton } from './tip-button'
export function LikeMarketButton(props: { export function LikeMarketButton(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
}) { }) {
const { contract, user } = props const { contract, user } = props
const tips = useMarketTipTxns(contract.id).filter(
(txn) => txn.fromId === user?.id const tips = useMarketTipTxns(contract.id)
)
const totalTipped = useMemo(() => { const totalTipped = useMemo(() => {
return sum(tips.map((tip) => tip.amount)) return sum(tips.map((tip) => tip.amount))
}, [tips]) }, [tips])
const likes = useUserLikes(user?.id) const likes = useUserLikes(user?.id)
const [isLiking, setIsLiking] = useState(false)
const userLikedContractIds = likes const userLikedContractIds = likes
?.filter((l) => l.type === 'contract') ?.filter((l) => l.type === 'contract')
.map((l) => l.id) .map((l) => l.id)
const onLike = async () => { const onLike = async () => {
if (!user) return firebaseLogin() if (!user) return firebaseLogin()
await likeContract(user, contract)
setIsLiking(true)
likeContract(user, contract).catch(() => setIsLiking(false))
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
} }
return ( return (
<Button <TipButton
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike} onClick={onLike}
> tipAmount={LIKE_TIP_AMOUNT}
<Col className={'relative items-center sm:flex-row'}> totalTipped={totalTipped}
<HeartIcon userTipped={
className={clsx( !!user &&
'h-5 w-5 sm:h-6 sm:w-6', (isLiking ||
totalTipped > 0 ? 'mr-2' : '', userLikedContractIds?.includes(contract.id) ||
user && (!likes && !!contract.likedByUserIds?.includes(user.id)))
(userLikedContractIds?.includes(contract.id) || }
(!likes && contract.likedByUserIds?.includes(user.id))) disabled={contract.creatorId === user?.id}
? 'fill-red-500 text-red-500'
: ''
)}
/> />
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
) )
} }

View File

@ -1,27 +1,30 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CPMMContract } from 'common/contract' import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from './amount-input' import { AmountInput } from 'web/components/amount-input'
import { Row } from './layout/row' import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity' import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from './outcome-label' import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from './layout/col' import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user' import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
export function LiquidityPanel(props: { contract: CPMMContract }) { export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser() const user = useUser()
const lpShares = useUserLiquidity(contract, user?.id ?? '') // eslint-disable-next-line react-hooks/rules-of-hooks
const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '')
const [showWithdrawal, setShowWithdrawal] = useState(false) const [showWithdrawal, setShowWithdrawal] = useState(false)
@ -33,16 +36,20 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
const isCreator = user?.id === contract.creatorId const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin() const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return ( return (
<Tabs <Tabs
tabs={buildArray( tabs={buildArray(
(isCreator || isAdmin) && { {
title: 'Bounty Comments',
content: <AddCommentBountyPanel contract={contract} />,
},
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />, content: <AddLiquidityPanel contract={contract} />,
}, },
showWithdrawal && { showWithdrawal &&
isCPMM && {
title: 'Withdraw', title: 'Withdraw',
content: ( content: (
<WithdrawLiquidityPanel <WithdrawLiquidityPanel
@ -51,7 +58,9 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
/> />
), ),
}, },
{
(isCreator || isAdmin) &&
isCPMM && {
title: 'Pool', title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />, content: <ViewLiquidityPanel contract={contract} />,
} }

View File

@ -1,99 +0,0 @@
import { DatumValue } from '@nivo/core'
import { Point, ResponsiveLine } from '@nivo/line'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { memo } from 'react'
import { range } from 'lodash'
import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
import { NumericContract } from '../../../common/contract'
import { useWindowSize } from '../../hooks/use-window-size'
import { Col } from '../layout/col'
import { formatLargeNumber } from 'common/util/format'
export const NumericGraph = memo(function NumericGraph(props: {
contract: NumericContract
height?: number
}) {
const { contract, height } = props
const { totalShares, bucketCount, min, max } = contract
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
const xs = range(bucketCount).map(
(i) => min + ((max - min) * i) / bucketCount
)
const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100)
const points = probs.map((prob, i) => ({ x: xs[i], y: prob }))
const maxProb = Math.max(...probs)
const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
const yTickValues = [
0,
0.25 * maxProb,
0.5 & maxProb,
0.75 * maxProb,
maxProb,
]
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: maxProb, type: 'linear' }}
yFormat={formatPercent}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'linear',
min: min,
max: max,
}}
xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
axisBottom={{
tickValues: numXTickValues,
format: (d) => `${formatLargeNumber(+d, 3)}`,
}}
colors={{ datum: 'color' }}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
const p = Math.round(+y * 100) / 100
return `${p}%`
}
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{formatLargeNumber(+point.data.x)}</div>
</Col>
)
}

View File

@ -1,3 +1,4 @@
import { sortBy } from 'lodash'
import clsx from 'clsx' import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract' import { CPMMContract } from 'common/contract'
@ -6,24 +7,24 @@ import { SiteLink } from '../site-link'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { useContractWithPreload } from 'web/hooks/use-contract'
export function ProbChangeTable(props: { export function ProbChangeTable(props: {
changes: changes: CPMMContract[] | undefined
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
full?: boolean full?: boolean
}) { }) {
const { changes, full } = props const { changes, full } = props
if (!changes) return <LoadingIndicator /> if (!changes) return <LoadingIndicator />
const { positiveChanges, negativeChanges } = changes const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse()
const ascendingChanges = sortBy(changes, (c) => c.probChanges.day)
const threshold = 0.01 const threshold = 0.01
const positiveAboveThreshold = positiveChanges.filter( const positiveAboveThreshold = descendingChanges.filter(
(c) => c.probChanges.day > threshold (c) => c.probChanges.day > threshold
) )
const negativeAboveThreshold = negativeChanges.filter( const negativeAboveThreshold = ascendingChanges.filter(
(c) => c.probChanges.day < threshold (c) => c.probChanges.day < threshold
) )
const maxRows = Math.min( const maxRows = Math.min(
@ -53,10 +54,20 @@ export function ProbChangeTable(props: {
) )
} }
function ProbChangeRow(props: { contract: CPMMContract }) { export function ProbChangeRow(props: {
const { contract } = props contract: CPMMContract
className?: string
}) {
const { className } = props
const contract =
(useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
return ( return (
<Row className="items-center justify-between gap-4 hover:bg-gray-100"> <Row
className={clsx(
'items-center justify-between gap-4 hover:bg-gray-100',
className
)}
>
<SiteLink <SiteLink
className="p-4 pr-0 font-semibold text-indigo-700" className="p-4 pr-0 font-semibold text-indigo-700"
href={contractPath(contract)} href={contractPath(contract)}

View File

@ -344,7 +344,7 @@ export function getColor(contract: Contract) {
return ( return (
OUTCOME_TO_COLOR[resolution as resolution] ?? OUTCOME_TO_COLOR[resolution as resolution] ??
// If resolved to a FR answer, use 'primary' // If resolved to a FR answer, use 'primary'
'primary' 'teal-500'
) )
} }
@ -355,5 +355,5 @@ export function getColor(contract: Contract) {
} }
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind // TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
return 'primary' return 'teal-500'
} }

View File

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

View File

@ -0,0 +1,61 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney } from 'common/util/format'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
export function TipButton(props: {
tipAmount: number
totalTipped: number
onClick: () => void
userTipped: boolean
isCompact?: boolean
disabled?: boolean
}) {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
props
return (
<Tooltip
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`}
placement="bottom"
noTap
noFade
>
<Button
size={'sm'}
className={clsx(
'max-w-xs self-center',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick}
disabled={disabled}
>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : ''
)}
/>
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
</Tooltip>
)
}

View File

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

View File

@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal'
import { import {
CheckIcon, CheckIcon,
CodeIcon, CodeIcon,
EyeOffIcon,
PhotographIcon, PhotographIcon,
PresentationChartLineIcon, PresentationChartLineIcon,
TrashIcon, TrashIcon,
@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon'
import ItalicIcon from 'web/lib/icons/italic-icon' import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon' import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse' import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -107,6 +109,9 @@ export function useTextEditor(props: {
}), }),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
], ],
content: defaultValue, content: defaultValue,
}) })
@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) {
const isBold = editor.isActive('bold') const isBold = editor.isActive('bold')
const isItalic = editor.isActive('italic') const isItalic = editor.isActive('italic')
const isLink = editor.isActive('link') const isLink = editor.isActive('link')
const isSpoiler = editor.isActive('spoiler')
const setLink = () => { const setLink = () => {
const href = url && getUrl(url) const href = url && getUrl(url)
@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) {
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}> <button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
</button> </button>
<button onClick={() => editor.chain().focus().toggleSpoiler().run()}>
<EyeOffIcon
className={clsx('h-5', isSpoiler && 'text-indigo-200')}
/>
</button>
</> </>
) : ( ) : (
<> <>
@ -329,6 +340,11 @@ export function RichContent(props: {
}), }),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text',
spoilerCloseClass:
'rounded-sm bg-greyscale-6 text-greyscale-6 cursor-pointer select-none',
}),
], ],
content, content,
editable: false, editable: false,

View File

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

View File

@ -1,7 +1,7 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -10,11 +10,10 @@ import clsx from 'clsx'
import { import {
ContractCommentInput, ContractCommentInput,
FeedComment, FeedComment,
ReplyTo,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
@ -27,32 +26,17 @@ export function FeedAnswerCommentGroup(props: {
const { answer, contract, answerComments, tips } = props const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] = const [replyTo, setReplyTo] = useState<ReplyTo>()
useState<Pick<User, 'id' | 'username'>>()
const [showReply, setShowReply] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const scrollAndOpenReplyInput = useEvent( const answerRef = useRef<HTMLDivElement>(null)
(comment?: ContractComment, answer?: Answer) => {
setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }
: answer
? { id: answer.userId, username: answer.username }
: undefined
)
setShowReply(true)
}
)
useEffect(() => { useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (highlighted && answerRef.current != null) {
setHighlighted(true) answerRef.current.scrollIntoView(true)
} }
}, [answerElementId, router.asPath]) }, [highlighted])
return ( return (
<Col className="relative flex-1 items-stretch gap-3"> <Col className="relative flex-1 items-stretch gap-3">
@ -61,6 +45,7 @@ export function FeedAnswerCommentGroup(props: {
'gap-3 space-x-3 pt-4 transition-all duration-1000', 'gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)} )}
ref={answerRef}
id={answerElementId} id={answerElementId}
> >
<Avatar username={username} avatarUrl={avatarUrl} /> <Avatar username={username} avatarUrl={avatarUrl} />
@ -83,7 +68,9 @@ export function FeedAnswerCommentGroup(props: {
<div className="sm:hidden"> <div className="sm:hidden">
<button <button
className="text-xs font-bold text-gray-500 hover:underline" className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)} onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
> >
Reply Reply
</button> </button>
@ -92,7 +79,9 @@ export function FeedAnswerCommentGroup(props: {
<div className="justify-initial hidden sm:block"> <div className="justify-initial hidden sm:block">
<button <button
className="text-xs font-bold text-gray-500 hover:underline" className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)} onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
> >
Reply Reply
</button> </button>
@ -107,11 +96,13 @@ export function FeedAnswerCommentGroup(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id] ?? {}} tips={tips[comment.id] ?? {}}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
/> />
))} ))}
</Col> </Col>
{showReply && ( {replyTo && (
<div className="relative ml-7"> <div className="relative ml-7">
<span <span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
@ -120,8 +111,8 @@ export function FeedAnswerCommentGroup(props: {
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
parentAnswerOutcome={answer.number.toString()} parentAnswerOutcome={answer.number.toString()}
replyToUser={replyToUser} replyTo={replyTo}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => setReplyTo(undefined)}
/> />
</div> </div>
)} )}

Some files were not shown because too many files have changed in this diff Show More