Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
		
						commit
						9328e9a238
					
				| 
						 | 
				
			
			@ -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 periodFilteredContracts = userContracts.filter(
 | 
			
		||||
    (contract) => contract.createdTime >= startTime
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
 | 
			
		|||
  userName: string
 | 
			
		||||
  userUsername: string
 | 
			
		||||
  userAvatarUrl?: string
 | 
			
		||||
  bountiesAwarded?: number
 | 
			
		||||
} & T
 | 
			
		||||
 | 
			
		||||
export type OnContract = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,10 +57,12 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
 | 
			
		|||
  uniqueBettorIds?: string[]
 | 
			
		||||
  uniqueBettorCount?: number
 | 
			
		||||
  popularityScore?: number
 | 
			
		||||
  dailyScore?: number
 | 
			
		||||
  followerCount?: number
 | 
			
		||||
  featuredOnHomeRank?: number
 | 
			
		||||
  likedByUserIds?: string[]
 | 
			
		||||
  likedByUserCount?: number
 | 
			
		||||
  openCommentBounties?: number
 | 
			
		||||
} & T
 | 
			
		||||
 | 
			
		||||
export type BinaryContract = Contract & Binary
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
 | 
			
		||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
 | 
			
		||||
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
 | 
			
		|||
  amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
 | 
			
		||||
  // this is Phil's deployment
 | 
			
		||||
  twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
 | 
			
		||||
  sprigEnvironmentId: 'Tu7kRZPm7daP',
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ export type EnvConfig = {
 | 
			
		|||
  firebaseConfig: FirebaseConfig
 | 
			
		||||
  amplitudeApiKey?: string
 | 
			
		||||
  twitchBotEndpoint?: string
 | 
			
		||||
  sprigEnvironmentId?: string
 | 
			
		||||
 | 
			
		||||
  // IDs for v2 cloud functions -- find these by deploying a cloud function and
 | 
			
		||||
  // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,7 @@ export type Economy = {
 | 
			
		|||
  BETTING_STREAK_BONUS_MAX?: number
 | 
			
		||||
  BETTING_STREAK_RESET_HOUR?: number
 | 
			
		||||
  FREE_MARKETS_PER_USER_MAX?: number
 | 
			
		||||
  COMMENT_BOUNTY_AMOUNT?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FirebaseConfig = {
 | 
			
		||||
| 
						 | 
				
			
			@ -56,6 +58,7 @@ type FirebaseConfig = {
 | 
			
		|||
export const PROD_CONFIG: EnvConfig = {
 | 
			
		||||
  domain: 'manifold.markets',
 | 
			
		||||
  amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
 | 
			
		||||
  sprigEnvironmentId: 'sQcrq9TDqkib',
 | 
			
		||||
 | 
			
		||||
  firebaseConfig: {
 | 
			
		||||
    apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ export type Group = {
 | 
			
		|||
  totalContracts: number
 | 
			
		||||
  totalMembers: number
 | 
			
		||||
  aboutPostId?: string
 | 
			
		||||
  postIds: string[]
 | 
			
		||||
  chatDisabled?: boolean
 | 
			
		||||
  mostRecentContractAddedTime?: number
 | 
			
		||||
  cachedLeaderboard?: {
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +23,7 @@ export type Group = {
 | 
			
		|||
      score: number
 | 
			
		||||
    }[]
 | 
			
		||||
  }
 | 
			
		||||
  pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MAX_GROUP_NAME_LENGTH = 75
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,4 +5,4 @@ export type Like = {
 | 
			
		|||
  createdTime: number
 | 
			
		||||
  tipTxnId?: string // only holds most recent tip txn id
 | 
			
		||||
}
 | 
			
		||||
export const LIKE_TIP_AMOUNT = 5
 | 
			
		||||
export const LIKE_TIP_AMOUNT = 10
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -116,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
 | 
			
		|||
    detailed: "Only answers by market creator on markets you're watching",
 | 
			
		||||
  },
 | 
			
		||||
  betting_streaks: {
 | 
			
		||||
    simple: 'For predictions made over consecutive days',
 | 
			
		||||
    detailed: 'Bonuses for predictions made over consecutive days',
 | 
			
		||||
    simple: `For prediction streaks`,
 | 
			
		||||
    detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
 | 
			
		||||
  },
 | 
			
		||||
  comments_by_followed_users_on_watched_markets: {
 | 
			
		||||
    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',
 | 
			
		||||
  },
 | 
			
		||||
  profit_loss_updates: {
 | 
			
		||||
    simple: 'Weekly profit and loss updates',
 | 
			
		||||
    detailed: 'Weekly profit and loss updates',
 | 
			
		||||
    simple: 'Weekly portfolio updates',
 | 
			
		||||
    detailed: 'Weekly portfolio updates',
 | 
			
		||||
  },
 | 
			
		||||
  referral_bonuses: {
 | 
			
		||||
    simple: 'For referring new users',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
 | 
			
		|||
    const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
 | 
			
		||||
    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 }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,4 +9,11 @@ export type Post = {
 | 
			
		|||
  slug: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type DateDoc = Post & {
 | 
			
		||||
  bounty: number
 | 
			
		||||
  birthday: number
 | 
			
		||||
  type: 'date-doc'
 | 
			
		||||
  contractSlug: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MAX_POST_TITLE_LENGTH = 480
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,7 @@ type AnyTxnType =
 | 
			
		|||
  | UniqueBettorBonus
 | 
			
		||||
  | BettingStreakBonus
 | 
			
		||||
  | CancelUniqueBettorBonus
 | 
			
		||||
  | CommentBountyRefund
 | 
			
		||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
 | 
			
		||||
 | 
			
		||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
 | 
			
		|||
    | 'UNIQUE_BETTOR_BONUS'
 | 
			
		||||
    | 'BETTING_STREAK_BONUS'
 | 
			
		||||
    | 'CANCEL_UNIQUE_BETTOR_BONUS'
 | 
			
		||||
    | 'COMMENT_BOUNTY'
 | 
			
		||||
    | 'REFUND_COMMENT_BOUNTY'
 | 
			
		||||
 | 
			
		||||
  // Any extra data
 | 
			
		||||
  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 TipTxn = Txn & Tip
 | 
			
		||||
export type ManalinkTxn = Txn & Manalink
 | 
			
		||||
| 
						 | 
				
			
			@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
 | 
			
		|||
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
 | 
			
		||||
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
 | 
			
		||||
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
 | 
			
		||||
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
 | 
			
		||||
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,6 +57,7 @@ export type PrivateUser = {
 | 
			
		|||
 | 
			
		||||
  email?: string
 | 
			
		||||
  weeklyTrendingEmailSent?: boolean
 | 
			
		||||
  weeklyPortfolioUpdateEmailSent?: boolean
 | 
			
		||||
  manaBonusEmailSent?: boolean
 | 
			
		||||
  initialDeviceToken?: string
 | 
			
		||||
  initialIpAddress?: string
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', {
 | 
			
		|||
})
 | 
			
		||||
 | 
			
		||||
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('$', '')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe'
 | 
			
		|||
import TiptapTweet from './tiptap-tweet-type'
 | 
			
		||||
import { find } from 'linkifyjs'
 | 
			
		||||
import { uniq } from 'lodash'
 | 
			
		||||
import { TiptapSpoiler } from './tiptap-spoiler'
 | 
			
		||||
 | 
			
		||||
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
 | 
			
		||||
export function getUrl(text: string) {
 | 
			
		||||
| 
						 | 
				
			
			@ -103,6 +104,7 @@ export const exhibitExts = [
 | 
			
		|||
  Mention,
 | 
			
		||||
  Iframe,
 | 
			
		||||
  TiptapTweet,
 | 
			
		||||
  TiptapSpoiler,
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export function richTextToString(text?: JSONContent) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,6 @@
 | 
			
		|||
export const MINUTE_MS = 60 * 1000
 | 
			
		||||
export const HOUR_MS = 60 * MINUTE_MS
 | 
			
		||||
export const DAY_MS = 24 * HOUR_MS
 | 
			
		||||
 | 
			
		||||
export const sleep = (ms: number) =>
 | 
			
		||||
  new Promise((resolve) => setTimeout(resolve, ms))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										116
									
								
								common/util/tiptap-spoiler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								common/util/tiptap-spoiler.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -55,6 +55,7 @@ Returns the authenticated user.
 | 
			
		|||
Gets all groups, in no particular order.
 | 
			
		||||
 | 
			
		||||
Parameters:
 | 
			
		||||
 | 
			
		||||
- `availableToUserId`: Optional. if specified, only groups that the user can
 | 
			
		||||
  join and groups they've already joined will be returned.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -64,24 +65,23 @@ Requires no authorization.
 | 
			
		|||
 | 
			
		||||
Gets a group by its slug.
 | 
			
		||||
 | 
			
		||||
Requires no authorization.  
 | 
			
		||||
Requires no authorization.
 | 
			
		||||
Note: group is singular in the URL.
 | 
			
		||||
 | 
			
		||||
### `GET /v0/group/by-id/[id]`
 | 
			
		||||
 | 
			
		||||
Gets a group by its unique ID.
 | 
			
		||||
 | 
			
		||||
Requires no authorization.  
 | 
			
		||||
Requires no authorization.
 | 
			
		||||
Note: group is singular in the URL.
 | 
			
		||||
 | 
			
		||||
### `GET /v0/group/by-id/[id]/markets`
 | 
			
		||||
 | 
			
		||||
Gets a group's markets by its unique ID.
 | 
			
		||||
 | 
			
		||||
Requires no authorization.  
 | 
			
		||||
Requires no authorization.
 | 
			
		||||
Note: group is singular in the URL.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### `GET /v0/markets`
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    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.
 | 
			
		||||
    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
 | 
			
		||||
    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
 | 
			
		||||
    volume7Days: number
 | 
			
		||||
| 
						 | 
				
			
			@ -408,7 +411,7 @@ Requires no authorization.
 | 
			
		|||
  type FullMarket = LiteMarket & {
 | 
			
		||||
    bets: Bet[]
 | 
			
		||||
    comments: Comment[]
 | 
			
		||||
    answers?: Answer[]
 | 
			
		||||
    answers?: Answer[] // dpm-2 markets only
 | 
			
		||||
    description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
 | 
			
		||||
    textDescription: string // string description without formatting, images, or embeds
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
 | 
			
		|||
 | 
			
		||||
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.
 | 
			
		||||
- `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).
 | 
			
		||||
| 
						 | 
				
			
			@ -569,6 +572,12 @@ For numeric markets, you must also provide:
 | 
			
		|||
 | 
			
		||||
- `min`: The minimum 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:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
 | 
			
		|||
                 "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`
 | 
			
		||||
 | 
			
		||||
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`.
 | 
			
		||||
- `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.
 | 
			
		||||
- `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:
 | 
			
		||||
 | 
			
		||||
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
 | 
			
		||||
- `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:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -745,6 +769,7 @@ Requires no authorization.
 | 
			
		|||
 | 
			
		||||
## 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-06-08: Add paging to markets endpoint
 | 
			
		||||
- 2022-06-05: Add new authorized write endpoints
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets.
 | 
			
		|||
 | 
			
		||||
## Sites using Manifold
 | 
			
		||||
 | 
			
		||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
 | 
			
		||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
 | 
			
		||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
 | 
			
		||||
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
 | 
			
		||||
 | 
			
		||||
## API / Dev
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
 | 
			
		|||
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
 | 
			
		||||
 | 
			
		||||
## Writeups
 | 
			
		||||
 | 
			
		||||
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
 | 
			
		||||
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
 | 
			
		||||
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
 | 
			
		||||
| 
						 | 
				
			
			@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets.
 | 
			
		|||
 | 
			
		||||
## Art
 | 
			
		||||
 | 
			
		||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox)  
 | 
			
		||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto)  
 | 
			
		||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) 
 | 
			
		||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
 | 
			
		||||
 | 
			
		||||
## 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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,11 +4,7 @@
 | 
			
		|||
 | 
			
		||||
### Do I have to pay real money in order to participate?
 | 
			
		||||
 | 
			
		||||
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
 | 
			
		||||
 | 
			
		||||
### What is the name for the currency Manifold uses, represented by M$?
 | 
			
		||||
 | 
			
		||||
Manifold Dollars, or mana for short.
 | 
			
		||||
Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
 | 
			
		||||
 | 
			
		||||
### Can M$ be sold for real money?
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -176,7 +176,7 @@ service cloud.firestore {
 | 
			
		|||
      allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
 | 
			
		||||
                        && request.resource.data.diff(resource.data)
 | 
			
		||||
                        .affectedKeys()
 | 
			
		||||
                        .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
 | 
			
		||||
                        .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
 | 
			
		||||
      allow delete: if request.auth.uid == resource.data.creatorId;
 | 
			
		||||
 | 
			
		||||
      match /groupContracts/{contractId} {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,7 +40,6 @@
 | 
			
		|||
    "mailgun-js": "0.22.0",
 | 
			
		||||
    "module-alias": "2.2.2",
 | 
			
		||||
    "node-fetch": "2",
 | 
			
		||||
    "react-masonry-css": "1.0.16",
 | 
			
		||||
    "stripe": "8.194.0",
 | 
			
		||||
    "zod": "3.17.2"
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +47,8 @@
 | 
			
		|||
    "@types/mailgun-js": "0.22.12",
 | 
			
		||||
    "@types/module-alias": "2.0.1",
 | 
			
		||||
    "@types/node-fetch": "2.6.2",
 | 
			
		||||
    "firebase-functions-test": "0.3.3"
 | 
			
		||||
    "firebase-functions-test": "0.3.3",
 | 
			
		||||
    "puppeteer": "18.0.5"
 | 
			
		||||
  },
 | 
			
		||||
  "private": true
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,7 +14,7 @@ import {
 | 
			
		|||
export { APIError } from '../../common/api'
 | 
			
		||||
 | 
			
		||||
type Output = Record<string, unknown>
 | 
			
		||||
type AuthedUser = {
 | 
			
		||||
export type AuthedUser = {
 | 
			
		||||
  uid: string
 | 
			
		||||
  creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										58
									
								
								functions/src/close-market.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								functions/src/close-market.ts
									
									
									
									
									
										Normal 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()
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
 | 
			
		|||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
 | 
			
		||||
import { getValues } from './utils'
 | 
			
		||||
import { APIError, newEndpoint, validate } from './api'
 | 
			
		||||
import { addUserToContractFollowers } from './follow-market'
 | 
			
		||||
 | 
			
		||||
const bodySchema = z.object({
 | 
			
		||||
  contractId: z.string().max(MAX_ANSWER_LENGTH),
 | 
			
		||||
| 
						 | 
				
			
			@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
 | 
			
		|||
    return answer
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await addUserToContractFollowers(contractId, auth.uid)
 | 
			
		||||
 | 
			
		||||
  return answer
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,6 +61,8 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
 | 
			
		|||
    anyoneCanJoin,
 | 
			
		||||
    totalContracts: 0,
 | 
			
		||||
    totalMembers: memberIds.length,
 | 
			
		||||
    postIds: [],
 | 
			
		||||
    pinnedItems: [],
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await groupRef.create(group)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
 | 
			
		|||
import { randomString } from '../../common/util/random'
 | 
			
		||||
 | 
			
		||||
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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
 | 
			
		|||
  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 {
 | 
			
		||||
    question,
 | 
			
		||||
    description,
 | 
			
		||||
| 
						 | 
				
			
			@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
 | 
			
		|||
    outcomeType,
 | 
			
		||||
    groupId,
 | 
			
		||||
    visibility = 'public',
 | 
			
		||||
  } = validate(bodySchema, req.body)
 | 
			
		||||
  } = validate(bodySchema, body)
 | 
			
		||||
 | 
			
		||||
  let min, max, initialProb, isLogScale, answers
 | 
			
		||||
 | 
			
		||||
  if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
 | 
			
		||||
    let initialValue
 | 
			
		||||
    ;({ min, max, initialValue, isLogScale } = validate(
 | 
			
		||||
      numericSchema,
 | 
			
		||||
      req.body
 | 
			
		||||
    ))
 | 
			
		||||
    ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
 | 
			
		||||
    if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
 | 
			
		||||
      throw new APIError(400, 'Invalid range.')
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  if (outcomeType === 'BINARY') {
 | 
			
		||||
    ;({ initialProb } = validate(binarySchema, req.body))
 | 
			
		||||
    ;({ initialProb } = validate(binarySchema, body))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (outcomeType === 'MULTIPLE_CHOICE') {
 | 
			
		||||
    ;({ answers } = validate(multipleChoiceSchema, req.body))
 | 
			
		||||
    ;({ answers } = validate(multipleChoiceSchema, body))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
  const newDescription =
 | 
			
		||||
    typeof description === 'string'
 | 
			
		||||
    !description || typeof description === 'string'
 | 
			
		||||
      ? {
 | 
			
		||||
          type: 'doc',
 | 
			
		||||
          content: [
 | 
			
		||||
            {
 | 
			
		||||
              type: 'paragraph',
 | 
			
		||||
              content: [{ type: 'text', text: description }],
 | 
			
		||||
              content: [{ type: 'text', text: description || ' ' }],
 | 
			
		||||
            },
 | 
			
		||||
          ],
 | 
			
		||||
        }
 | 
			
		||||
      : description ?? {}
 | 
			
		||||
      : description
 | 
			
		||||
 | 
			
		||||
  const contract = getNewContract(
 | 
			
		||||
    contractRef.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  return contract
 | 
			
		||||
})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getSlug = async (question: string) => {
 | 
			
		||||
  const proposedSlug = slugify(question)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
 | 
			
		|||
import { APIError, newEndpoint, validate } from './api'
 | 
			
		||||
import { JSONContent } from '@tiptap/core'
 | 
			
		||||
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(() =>
 | 
			
		||||
  z.intersection(
 | 
			
		||||
| 
						 | 
				
			
			@ -34,11 +37,21 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
 | 
			
		|||
const postSchema = z.object({
 | 
			
		||||
  title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
 | 
			
		||||
  content: contentSchema,
 | 
			
		||||
  groupId: z.string().optional(),
 | 
			
		||||
 | 
			
		||||
  // 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) => {
 | 
			
		||||
  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)
 | 
			
		||||
  if (!creator)
 | 
			
		||||
| 
						 | 
				
			
			@ -50,16 +63,50 @@ export const createpost = newEndpoint({}, async (req, auth) => {
 | 
			
		|||
 | 
			
		||||
  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,
 | 
			
		||||
    creatorId: creator.id,
 | 
			
		||||
    slug,
 | 
			
		||||
    title,
 | 
			
		||||
    createdTime: Date.now(),
 | 
			
		||||
    content: content,
 | 
			
		||||
  }
 | 
			
		||||
    contractSlug,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  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 }
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
							
								
								
									
										510
									
								
								functions/src/email-templates/weekly-portfolio-update.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										510
									
								
								functions/src/email-templates/weekly-portfolio-update.html
									
									
									
									
									
										Normal 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
 | 
			
		|||
import { formatNumericProbability } from '../../common/pseudo-numeric'
 | 
			
		||||
 | 
			
		||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
 | 
			
		||||
import { getUser } from './utils'
 | 
			
		||||
import { contractUrl, getUser } from './utils'
 | 
			
		||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
 | 
			
		||||
import { notification_reason_types } from '../../common/notification'
 | 
			
		||||
import { Dictionary } from 'lodash'
 | 
			
		||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
 | 
			
		||||
import {
 | 
			
		||||
  getNotificationDestinationsForUser,
 | 
			
		||||
  notification_preference,
 | 
			
		||||
} from '../../common/user-notification-preferences'
 | 
			
		||||
  PerContractInvestmentsData,
 | 
			
		||||
  OverallPerformanceData,
 | 
			
		||||
} from './weekly-portfolio-emails'
 | 
			
		||||
 | 
			
		||||
export const sendMarketResolutionEmail = async (
 | 
			
		||||
  reason: notification_reason_types,
 | 
			
		||||
| 
						 | 
				
			
			@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
 | 
			
		|||
  const { name } = user
 | 
			
		||||
  const firstName = name.split(' ')[0]
 | 
			
		||||
 | 
			
		||||
  const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
 | 
			
		||||
    'onboarding_flow' as notification_preference
 | 
			
		||||
  }`
 | 
			
		||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
			
		||||
    privateUser,
 | 
			
		||||
    'onboarding_flow'
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return await sendTemplateEmail(
 | 
			
		||||
    privateUser.email,
 | 
			
		||||
| 
						 | 
				
			
			@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
 | 
			
		|||
  const { name } = user
 | 
			
		||||
  const firstName = name.split(' ')[0]
 | 
			
		||||
 | 
			
		||||
  const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
 | 
			
		||||
    'onboarding_flow' as notification_preference
 | 
			
		||||
  }`
 | 
			
		||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
			
		||||
    privateUser,
 | 
			
		||||
    'onboarding_flow'
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return await sendTemplateEmail(
 | 
			
		||||
    privateUser.email,
 | 
			
		||||
    'Manifold Markets one week anniversary gift',
 | 
			
		||||
| 
						 | 
				
			
			@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
 | 
			
		|||
 | 
			
		||||
  const { name } = user
 | 
			
		||||
  const firstName = name.split(' ')[0]
 | 
			
		||||
 | 
			
		||||
  const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
 | 
			
		||||
    'onboarding_flow' as notification_preference
 | 
			
		||||
  }`
 | 
			
		||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
			
		||||
    privateUser,
 | 
			
		||||
    'onboarding_flow'
 | 
			
		||||
  )
 | 
			
		||||
  return await sendTemplateEmail(
 | 
			
		||||
    privateUser.email,
 | 
			
		||||
    'Create your own prediction market',
 | 
			
		||||
| 
						 | 
				
			
			@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
 | 
			
		|||
 | 
			
		||||
  const { name } = user
 | 
			
		||||
  const firstName = name.split(' ')[0]
 | 
			
		||||
 | 
			
		||||
  const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
 | 
			
		||||
    'thank_you_for_purchases' as notification_preference
 | 
			
		||||
  }`
 | 
			
		||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
			
		||||
    privateUser,
 | 
			
		||||
    'thank_you_for_purchases'
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return await sendTemplateEmail(
 | 
			
		||||
    privateUser.email,
 | 
			
		||||
| 
						 | 
				
			
			@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
 | 
			
		|||
  )
 | 
			
		||||
    return
 | 
			
		||||
 | 
			
		||||
  const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
 | 
			
		||||
    'trending_markets' as notification_preference
 | 
			
		||||
  }`
 | 
			
		||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
			
		||||
    privateUser,
 | 
			
		||||
    'trending_markets'
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const { name } = user
 | 
			
		||||
  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) {
 | 
			
		||||
  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
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,9 +27,10 @@ export * from './on-delete-group'
 | 
			
		|||
export * from './score-contracts'
 | 
			
		||||
export * from './weekly-markets-emails'
 | 
			
		||||
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-like'
 | 
			
		||||
export * from './weekly-portfolio-emails'
 | 
			
		||||
 | 
			
		||||
// v2
 | 
			
		||||
export * from './health'
 | 
			
		||||
| 
						 | 
				
			
			@ -50,6 +51,8 @@ export * from './resolve-market'
 | 
			
		|||
export * from './unsubscribe'
 | 
			
		||||
export * from './stripe'
 | 
			
		||||
export * from './mana-bonus-email'
 | 
			
		||||
export * from './close-market'
 | 
			
		||||
export * from './update-comment-bounty'
 | 
			
		||||
 | 
			
		||||
import { health } from './health'
 | 
			
		||||
import { transact } from './transact'
 | 
			
		||||
| 
						 | 
				
			
			@ -63,9 +66,11 @@ import { sellshares } from './sell-shares'
 | 
			
		|||
import { claimmanalink } from './claim-manalink'
 | 
			
		||||
import { createmarket } from './create-market'
 | 
			
		||||
import { addliquidity } from './add-liquidity'
 | 
			
		||||
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
 | 
			
		||||
import { withdrawliquidity } from './withdraw-liquidity'
 | 
			
		||||
import { creategroup } from './create-group'
 | 
			
		||||
import { resolvemarket } from './resolve-market'
 | 
			
		||||
import { closemarket } from './close-market'
 | 
			
		||||
import { unsubscribe } from './unsubscribe'
 | 
			
		||||
import { stripewebhook, createcheckoutsession } from './stripe'
 | 
			
		||||
import { getcurrentuser } from './get-current-user'
 | 
			
		||||
| 
						 | 
				
			
			@ -88,9 +93,12 @@ const sellSharesFunction = toCloudFunction(sellshares)
 | 
			
		|||
const claimManalinkFunction = toCloudFunction(claimmanalink)
 | 
			
		||||
const createMarketFunction = toCloudFunction(createmarket)
 | 
			
		||||
const addLiquidityFunction = toCloudFunction(addliquidity)
 | 
			
		||||
const addCommentBounty = toCloudFunction(addcommentbounty)
 | 
			
		||||
const awardCommentBounty = toCloudFunction(awardcommentbounty)
 | 
			
		||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
 | 
			
		||||
const createGroupFunction = toCloudFunction(creategroup)
 | 
			
		||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
 | 
			
		||||
const closeMarketFunction = toCloudFunction(closemarket)
 | 
			
		||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
 | 
			
		||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
 | 
			
		||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
 | 
			
		||||
| 
						 | 
				
			
			@ -115,11 +123,14 @@ export {
 | 
			
		|||
  withdrawLiquidityFunction as withdrawliquidity,
 | 
			
		||||
  createGroupFunction as creategroup,
 | 
			
		||||
  resolveMarketFunction as resolvemarket,
 | 
			
		||||
  closeMarketFunction as closemarket,
 | 
			
		||||
  unsubscribeFunction as unsubscribe,
 | 
			
		||||
  stripeWebhookFunction as stripewebhook,
 | 
			
		||||
  createCheckoutSessionFunction as createcheckoutsession,
 | 
			
		||||
  getCurrentUserFunction as getcurrentuser,
 | 
			
		||||
  acceptChallenge as acceptchallenge,
 | 
			
		||||
  createPostFunction as createpost,
 | 
			
		||||
  saveTwitchCredentials as savetwitchcredentials
 | 
			
		||||
  saveTwitchCredentials as savetwitchcredentials,
 | 
			
		||||
  addCommentBounty as addcommentbounty,
 | 
			
		||||
  awardCommentBounty as awardcommentbounty,
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
 | 
			
		|||
      'contract',
 | 
			
		||||
      'closed',
 | 
			
		||||
      user,
 | 
			
		||||
      'closed' + contract.id.slice(6, contract.id.length),
 | 
			
		||||
      contract.id + '-closed-at-' + contract.closeTime,
 | 
			
		||||
      contract.closeTime?.toString() ?? new Date().toString(),
 | 
			
		||||
      { contract }
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,44 +1,118 @@
 | 
			
		|||
import * as functions from 'firebase-functions'
 | 
			
		||||
import { getUser } from './utils'
 | 
			
		||||
import { getUser, getValues, log } from './utils'
 | 
			
		||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
 | 
			
		||||
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
 | 
			
		||||
  .document('contracts/{contractId}')
 | 
			
		||||
  .onUpdate(async (change, context) => {
 | 
			
		||||
    const contract = change.after.data() as Contract
 | 
			
		||||
    const previousContract = change.before.data() as Contract
 | 
			
		||||
    const { eventId } = context
 | 
			
		||||
 | 
			
		||||
    const contractUpdater = await getUser(contract.creatorId)
 | 
			
		||||
    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
 | 
			
		||||
    const { openCommentBounties, closeTime, question } = contract
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      previousValue.closeTime !== contract.closeTime ||
 | 
			
		||||
      previousValue.question !== contract.question
 | 
			
		||||
      !previousContract.isResolved &&
 | 
			
		||||
      contract.isResolved &&
 | 
			
		||||
      (openCommentBounties ?? 0) > 0
 | 
			
		||||
    ) {
 | 
			
		||||
      let sourceText = ''
 | 
			
		||||
      if (
 | 
			
		||||
        previousValue.closeTime !== contract.closeTime &&
 | 
			
		||||
        contract.closeTime
 | 
			
		||||
      ) {
 | 
			
		||||
        sourceText = contract.closeTime.toString()
 | 
			
		||||
      } else if (previousValue.question !== contract.question) {
 | 
			
		||||
        sourceText = contract.question
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await createCommentOrAnswerOrUpdatedContractNotification(
 | 
			
		||||
        contract.id,
 | 
			
		||||
        'contract',
 | 
			
		||||
        'updated',
 | 
			
		||||
        contractUpdater,
 | 
			
		||||
        eventId,
 | 
			
		||||
        sourceText,
 | 
			
		||||
        contract
 | 
			
		||||
      )
 | 
			
		||||
      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)
 | 
			
		||||
  if (!contractUpdater) throw new Error('Could not find contract updater')
 | 
			
		||||
  let sourceText = ''
 | 
			
		||||
  if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
 | 
			
		||||
    sourceText = contract.closeTime.toString()
 | 
			
		||||
  } else if (previousContract.question !== contract.question) {
 | 
			
		||||
    sourceText = contract.question
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await createCommentOrAnswerOrUpdatedContractNotification(
 | 
			
		||||
    contract.id,
 | 
			
		||||
    'contract',
 | 
			
		||||
    'updated',
 | 
			
		||||
    contractUpdater,
 | 
			
		||||
    eventId,
 | 
			
		||||
    sourceText,
 | 
			
		||||
    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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
 | 
			
		|||
import * as admin from 'firebase-admin'
 | 
			
		||||
import { getAllPrivateUsers } from './utils'
 | 
			
		||||
 | 
			
		||||
export const resetWeeklyEmailsFlag = functions
 | 
			
		||||
export const resetWeeklyEmailsFlags = functions
 | 
			
		||||
  .runWith({
 | 
			
		||||
    timeoutSeconds: 300,
 | 
			
		||||
    memory: '4GB',
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
 | 
			
		|||
      privateUsers.map(async (user) => {
 | 
			
		||||
        return firestore.collection('private-users').doc(user.id).update({
 | 
			
		||||
          weeklyTrendingEmailSent: false,
 | 
			
		||||
          weeklyPortfolioUpdateEmailSent: false,
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			@ -1,12 +1,15 @@
 | 
			
		|||
import * as functions from 'firebase-functions'
 | 
			
		||||
import * as admin from 'firebase-admin'
 | 
			
		||||
import { Bet } from 'common/bet'
 | 
			
		||||
import { uniq } from 'lodash'
 | 
			
		||||
import { Contract } from 'common/contract'
 | 
			
		||||
import { Bet } from '../../common/bet'
 | 
			
		||||
import { Contract } from '../../common/contract'
 | 
			
		||||
import { log } from './utils'
 | 
			
		||||
import { removeUndefinedProps } from '../../common/util/object'
 | 
			
		||||
import { DAY_MS, HOUR_MS } from '../../common/util/time'
 | 
			
		||||
 | 
			
		||||
export const scoreContracts = functions.pubsub
 | 
			
		||||
  .schedule('every 1 hours')
 | 
			
		||||
export const scoreContracts = functions
 | 
			
		||||
  .runWith({ memory: '4GB', timeoutSeconds: 540 })
 | 
			
		||||
  .pubsub.schedule('every 1 hours')
 | 
			
		||||
  .onRun(async () => {
 | 
			
		||||
    await scoreContractsInternal()
 | 
			
		||||
  })
 | 
			
		||||
| 
						 | 
				
			
			@ -14,11 +17,12 @@ const firestore = admin.firestore()
 | 
			
		|||
 | 
			
		||||
async function scoreContractsInternal() {
 | 
			
		||||
  const now = Date.now()
 | 
			
		||||
  const lastHour = now - 60 * 60 * 1000
 | 
			
		||||
  const last3Days = now - 1000 * 60 * 60 * 24 * 3
 | 
			
		||||
  const hourAgo = now - HOUR_MS
 | 
			
		||||
  const dayAgo = now - DAY_MS
 | 
			
		||||
  const threeDaysAgo = now - DAY_MS * 3
 | 
			
		||||
  const activeContractsSnap = await firestore
 | 
			
		||||
    .collection('contracts')
 | 
			
		||||
    .where('lastUpdatedTime', '>', lastHour)
 | 
			
		||||
    .where('lastUpdatedTime', '>', hourAgo)
 | 
			
		||||
    .get()
 | 
			
		||||
  const activeContracts = activeContractsSnap.docs.map(
 | 
			
		||||
    (doc) => doc.data() as Contract
 | 
			
		||||
| 
						 | 
				
			
			@ -39,16 +43,33 @@ async function scoreContractsInternal() {
 | 
			
		|||
  for (const contract of contracts) {
 | 
			
		||||
    const bets = await firestore
 | 
			
		||||
      .collection(`contracts/${contract.id}/bets`)
 | 
			
		||||
      .where('createdTime', '>', last3Days)
 | 
			
		||||
      .where('createdTime', '>', threeDaysAgo)
 | 
			
		||||
      .get()
 | 
			
		||||
    const bettors = bets.docs
 | 
			
		||||
      .map((doc) => doc.data() as Bet)
 | 
			
		||||
      .map((bet) => bet.userId)
 | 
			
		||||
    const score = uniq(bettors).length
 | 
			
		||||
    if (contract.popularityScore !== score)
 | 
			
		||||
    const popularityScore = uniq(bettors).length
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
        .collection('contracts')
 | 
			
		||||
        .doc(contract.id)
 | 
			
		||||
        .update({ popularityScore: score })
 | 
			
		||||
        .update(removeUndefinedProps({ popularityScore, dailyScore }))
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										52
									
								
								functions/src/scripts/contest/bulk-add-liquidity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								functions/src/scripts/contest/bulk-add-liquidity.ts
									
									
									
									
									
										Normal 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()
 | 
			
		||||
							
								
								
									
										115
									
								
								functions/src/scripts/contest/bulk-create-markets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								functions/src/scripts/contest/bulk-create-markets.ts
									
									
									
									
									
										Normal 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',
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1219
									
								
								functions/src/scripts/contest/criticism-and-red-teaming.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1219
									
								
								functions/src/scripts/contest/criticism-and-red-teaming.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										55
									
								
								functions/src/scripts/contest/scrape-ea.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								functions/src/scripts/contest/scrape-ea.ts
									
									
									
									
									
										Normal 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 '&' with '&'
 | 
			
		||||
        const clean = (str: string | undefined) => str?.replace(/&/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'
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +41,8 @@ const createGroup = async (
 | 
			
		|||
    anyoneCanJoin: true,
 | 
			
		||||
    totalContracts: contracts.length,
 | 
			
		||||
    totalMembers: 1,
 | 
			
		||||
    postIds: [],
 | 
			
		||||
    pinnedItems: [],
 | 
			
		||||
  }
 | 
			
		||||
  await groupRef.create(group)
 | 
			
		||||
  // create a GroupMemberDoc for the creator
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,8 @@ import { stripewebhook, createcheckoutsession } from './stripe'
 | 
			
		|||
import { getcurrentuser } from './get-current-user'
 | 
			
		||||
import { createpost } from './create-post'
 | 
			
		||||
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
 | 
			
		||||
const app = express()
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
 | 
			
		|||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
 | 
			
		||||
addJsonEndpointRoute('/createmarket', createmarket)
 | 
			
		||||
addJsonEndpointRoute('/addliquidity', addliquidity)
 | 
			
		||||
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
 | 
			
		||||
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
 | 
			
		||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
 | 
			
		||||
addJsonEndpointRoute('/creategroup', creategroup)
 | 
			
		||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
 | 
			
		||||
| 
						 | 
				
			
			@ -69,6 +73,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
 | 
			
		|||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
 | 
			
		||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
 | 
			
		||||
addEndpointRoute('/createpost', createpost)
 | 
			
		||||
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
 | 
			
		||||
 | 
			
		||||
app.listen(PORT)
 | 
			
		||||
console.log(`Serving functions on port ${PORT}.`)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										17
									
								
								functions/src/test-scheduled-function.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								functions/src/test-scheduled-function.ts
									
									
									
									
									
										Normal 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 }
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										162
									
								
								functions/src/update-comment-bounty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								functions/src/update-comment-bounty.ts
									
									
									
									
									
										Normal 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()
 | 
			
		||||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ import { filterDefined } from '../../common/util/array'
 | 
			
		|||
const firestore = admin.firestore()
 | 
			
		||||
 | 
			
		||||
export const updateLoans = functions
 | 
			
		||||
  .runWith({ memory: '2GB', timeoutSeconds: 540 })
 | 
			
		||||
  .runWith({ memory: '8GB', timeoutSeconds: 540 })
 | 
			
		||||
  // Run every day at midnight.
 | 
			
		||||
  .pubsub.schedule('0 0 * * *')
 | 
			
		||||
  .timeZone('America/Los_Angeles')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,38 +17,57 @@ import {
 | 
			
		|||
  computeVolume,
 | 
			
		||||
} from '../../common/calculate-metrics'
 | 
			
		||||
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()
 | 
			
		||||
 | 
			
		||||
export const updateMetrics = functions
 | 
			
		||||
  .runWith({ memory: '4GB', timeoutSeconds: 540 })
 | 
			
		||||
  .runWith({ memory: '8GB', timeoutSeconds: 540 })
 | 
			
		||||
  .pubsub.schedule('every 15 minutes')
 | 
			
		||||
  .onRun(updateMetricsCore)
 | 
			
		||||
 | 
			
		||||
export async function updateMetricsCore() {
 | 
			
		||||
  const [users, contracts, bets, allPortfolioHistories, groups] =
 | 
			
		||||
    await Promise.all([
 | 
			
		||||
      getValues<User>(firestore.collection('users')),
 | 
			
		||||
      getValues<Contract>(firestore.collection('contracts')),
 | 
			
		||||
      getValues<Bet>(firestore.collectionGroup('bets')),
 | 
			
		||||
      getValues<PortfolioMetrics>(
 | 
			
		||||
        firestore
 | 
			
		||||
          .collectionGroup('portfolioHistory')
 | 
			
		||||
          .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
 | 
			
		||||
      ),
 | 
			
		||||
      getValues<Group>(firestore.collection('groups')),
 | 
			
		||||
    ])
 | 
			
		||||
  console.log('Loading users')
 | 
			
		||||
  const users = await getValues<User>(firestore.collection('users'))
 | 
			
		||||
 | 
			
		||||
  console.log('Loading contracts')
 | 
			
		||||
  const contracts = await getValues<Contract>(firestore.collection('contracts'))
 | 
			
		||||
 | 
			
		||||
  console.log('Loading portfolio history')
 | 
			
		||||
  const allPortfolioHistories = await getValues<PortfolioMetrics>(
 | 
			
		||||
    firestore
 | 
			
		||||
      .collectionGroup('portfolioHistory')
 | 
			
		||||
      .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  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(
 | 
			
		||||
    groups.map((group) => {
 | 
			
		||||
      return getValues(
 | 
			
		||||
    groups.map((group) =>
 | 
			
		||||
      getValues(
 | 
			
		||||
        firestore
 | 
			
		||||
          .collection('groups')
 | 
			
		||||
          .doc(group.id)
 | 
			
		||||
          .collection('groupContracts')
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
  log(
 | 
			
		||||
    `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
 | 
			
		|||
 | 
			
		||||
const firestore = admin.firestore()
 | 
			
		||||
 | 
			
		||||
const numberOfDays = 90
 | 
			
		||||
const numberOfDays = 180
 | 
			
		||||
 | 
			
		||||
const getBetsQuery = (startTime: number, endTime: number) =>
 | 
			
		||||
  firestore
 | 
			
		||||
| 
						 | 
				
			
			@ -343,6 +343,6 @@ export const updateStatsCore = async () => {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export const updateStats = functions
 | 
			
		||||
  .runWith({ memory: '2GB', timeoutSeconds: 540 })
 | 
			
		||||
  .runWith({ memory: '4GB', timeoutSeconds: 540 })
 | 
			
		||||
  .pubsub.schedule('every 60 minutes')
 | 
			
		||||
  .onRun(updateStatsCore)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -170,3 +170,7 @@ export const chargeUser = (
 | 
			
		|||
export const getContractPath = (contract: Contract) => {
 | 
			
		||||
  return `/${contract.creatorUsername}/${contract.slug}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function contractUrl(contract: Contract) {
 | 
			
		||||
  return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() {
 | 
			
		|||
    ? await getAllPrivateUsers()
 | 
			
		||||
    : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
 | 
			
		||||
  // get all users that haven't unsubscribed from weekly emails
 | 
			
		||||
  const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
 | 
			
		||||
    return (
 | 
			
		||||
      user.notificationPreferences.trending_markets.includes('email') &&
 | 
			
		||||
      !user.weeklyTrendingEmailSent
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
  const privateUsersToSendEmailsTo = privateUsers
 | 
			
		||||
    .filter((user) => {
 | 
			
		||||
      return (
 | 
			
		||||
        user.notificationPreferences.trending_markets.includes('email') &&
 | 
			
		||||
        !user.weeklyTrendingEmailSent
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
    .slice(150) // Send the emails out in batches
 | 
			
		||||
  log(
 | 
			
		||||
    'Sending weekly trending emails to',
 | 
			
		||||
    privateUsersToSendEmailsTo.length,
 | 
			
		||||
| 
						 | 
				
			
			@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
 | 
			
		|||
    trendingContracts.map((c) => c.question).join('\n ')
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // TODO: convert to Promise.all
 | 
			
		||||
  for (const privateUser of privateUsersToSendEmailsTo) {
 | 
			
		||||
    if (!privateUser.email) {
 | 
			
		||||
      log(`No email for ${privateUser.username}`)
 | 
			
		||||
| 
						 | 
				
			
			@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() {
 | 
			
		|||
    })
 | 
			
		||||
    if (contractsAvailableToSend.length < numContractsToSend) {
 | 
			
		||||
      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
 | 
			
		||||
    }
 | 
			
		||||
    // choose random subset of contracts to send to user
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										289
									
								
								functions/src/weekly-portfolio-emails.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								functions/src/weekly-portfolio-emails.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
 | 
			
		|||
 | 
			
		||||
      <div className="modal">
 | 
			
		||||
        <div className="modal-box">
 | 
			
		||||
          <div className="mb-6 text-xl">Get Manifold Dollars</div>
 | 
			
		||||
          <div className="mb-6 text-xl">Get Mana</div>
 | 
			
		||||
 | 
			
		||||
          <div className="mb-6 text-gray-500">
 | 
			
		||||
            Use Manifold Dollars to trade in your favorite markets. <br /> (Not
 | 
			
		||||
            Buy mana (M$) to trade in your favorite markets. <br /> (Not
 | 
			
		||||
            redeemable for cash.)
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { formatMoney } from 'common/util/format'
 | 
			
		|||
import { Col } from './layout/col'
 | 
			
		||||
import { SiteLink } from './site-link'
 | 
			
		||||
import { ENV_CONFIG } from 'common/envs/constants'
 | 
			
		||||
import { useWindowSize } from 'web/hooks/use-window-size'
 | 
			
		||||
import { Row } from './layout/row'
 | 
			
		||||
 | 
			
		||||
export function AmountInput(props: {
 | 
			
		||||
  amount: number | undefined
 | 
			
		||||
| 
						 | 
				
			
			@ -34,46 +34,49 @@ export function AmountInput(props: {
 | 
			
		|||
    const isInvalid = !str || isNaN(amount)
 | 
			
		||||
    onChange(isInvalid ? undefined : amount)
 | 
			
		||||
  }
 | 
			
		||||
  const { width } = useWindowSize()
 | 
			
		||||
  const isMobile = (width ?? 0) < 768
 | 
			
		||||
  return (
 | 
			
		||||
    <Col className={className}>
 | 
			
		||||
      <label className="input-group mb-4">
 | 
			
		||||
        <span className="bg-gray-200 text-sm">{label}</span>
 | 
			
		||||
        <input
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
 | 
			
		||||
            error && 'input-error',
 | 
			
		||||
            inputClassName
 | 
			
		||||
          )}
 | 
			
		||||
          ref={inputRef}
 | 
			
		||||
          type="text"
 | 
			
		||||
          pattern="[0-9]*"
 | 
			
		||||
          inputMode="numeric"
 | 
			
		||||
          placeholder="0"
 | 
			
		||||
          maxLength={6}
 | 
			
		||||
          autoFocus={!isMobile}
 | 
			
		||||
          value={amount ?? ''}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          onChange={(e) => onAmountChange(e.target.value)}
 | 
			
		||||
        />
 | 
			
		||||
      </label>
 | 
			
		||||
 | 
			
		||||
      {error && (
 | 
			
		||||
        <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
 | 
			
		||||
          {error === 'Insufficient balance' ? (
 | 
			
		||||
            <>
 | 
			
		||||
              Not enough funds.
 | 
			
		||||
              <span className="ml-1 text-indigo-500">
 | 
			
		||||
                <SiteLink href="/add-funds">Buy more?</SiteLink>
 | 
			
		||||
              </span>
 | 
			
		||||
            </>
 | 
			
		||||
          ) : (
 | 
			
		||||
            error
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </Col>
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Col className={className}>
 | 
			
		||||
        <label className="font-sm md:font-lg relative">
 | 
			
		||||
          <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
 | 
			
		||||
            {label}
 | 
			
		||||
          </span>
 | 
			
		||||
          <input
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
 | 
			
		||||
              error && 'input-error',
 | 
			
		||||
              'w-24 md:w-auto',
 | 
			
		||||
              inputClassName
 | 
			
		||||
            )}
 | 
			
		||||
            ref={inputRef}
 | 
			
		||||
            type="text"
 | 
			
		||||
            pattern="[0-9]*"
 | 
			
		||||
            inputMode="numeric"
 | 
			
		||||
            placeholder="0"
 | 
			
		||||
            maxLength={6}
 | 
			
		||||
            value={amount ?? ''}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
            onChange={(e) => onAmountChange(e.target.value)}
 | 
			
		||||
          />
 | 
			
		||||
        </label>
 | 
			
		||||
 | 
			
		||||
        {error && (
 | 
			
		||||
          <div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
 | 
			
		||||
            {error === 'Insufficient balance' ? (
 | 
			
		||||
              <>
 | 
			
		||||
                Not enough funds.
 | 
			
		||||
                <span className="ml-1 text-indigo-500">
 | 
			
		||||
                  <SiteLink href="/add-funds">Buy more?</SiteLink>
 | 
			
		||||
                </span>
 | 
			
		||||
              </>
 | 
			
		||||
            ) : (
 | 
			
		||||
              error
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </Col>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -136,27 +139,29 @@ export function BuyAmountInput(props: {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <AmountInput
 | 
			
		||||
        amount={amount}
 | 
			
		||||
        onChange={onAmountChange}
 | 
			
		||||
        label={ENV_CONFIG.moneyMoniker}
 | 
			
		||||
        error={error}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
        className={className}
 | 
			
		||||
        inputClassName={inputClassName}
 | 
			
		||||
        inputRef={inputRef}
 | 
			
		||||
      />
 | 
			
		||||
      {showSlider && (
 | 
			
		||||
        <input
 | 
			
		||||
          type="range"
 | 
			
		||||
          min="0"
 | 
			
		||||
          max="205"
 | 
			
		||||
          value={getRaw(amount ?? 0)}
 | 
			
		||||
          onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
 | 
			
		||||
          className="range range-lg only-thumb z-40 mb-2 xl:hidden"
 | 
			
		||||
          step="5"
 | 
			
		||||
      <Row className="gap-4">
 | 
			
		||||
        <AmountInput
 | 
			
		||||
          amount={amount}
 | 
			
		||||
          onChange={onAmountChange}
 | 
			
		||||
          label={ENV_CONFIG.moneyMoniker}
 | 
			
		||||
          error={error}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          className={className}
 | 
			
		||||
          inputClassName={inputClassName}
 | 
			
		||||
          inputRef={inputRef}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
        {showSlider && (
 | 
			
		||||
          <input
 | 
			
		||||
            type="range"
 | 
			
		||||
            min="0"
 | 
			
		||||
            max="205"
 | 
			
		||||
            value={getRaw(amount ?? 0)}
 | 
			
		||||
            onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
 | 
			
		||||
            className="range range-lg only-thumb my-auto align-middle xl:hidden"
 | 
			
		||||
            step="5"
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Row>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -182,18 +182,16 @@ export function AnswerBetPanel(props: {
 | 
			
		|||
      </Col>
 | 
			
		||||
 | 
			
		||||
      <Spacer h={6} />
 | 
			
		||||
 | 
			
		||||
      {user ? (
 | 
			
		||||
        <WarningConfirmationButton
 | 
			
		||||
          size="xl"
 | 
			
		||||
          marketType="freeResponse"
 | 
			
		||||
          amount={betAmount}
 | 
			
		||||
          warning={warning}
 | 
			
		||||
          onSubmit={submitBet}
 | 
			
		||||
          isSubmitting={isSubmitting}
 | 
			
		||||
          disabled={!!betDisabled}
 | 
			
		||||
          openModalButtonClass={clsx(
 | 
			
		||||
            'btn self-stretch',
 | 
			
		||||
            betDisabled ? 'btn-disabled' : 'btn-primary',
 | 
			
		||||
            isSubmitting ? 'loading' : ''
 | 
			
		||||
          )}
 | 
			
		||||
          color={'indigo'}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <BetSignUpPrompt />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: {
 | 
			
		|||
    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 (
 | 
			
		||||
    <Col className="gap-4 rounded">
 | 
			
		||||
      <Row className="justify-between">
 | 
			
		||||
| 
						 | 
				
			
			@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: {
 | 
			
		|||
              Clear
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <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}
 | 
			
		||||
            isSubmitting={isSubmitting}
 | 
			
		||||
            openModalButtonClass={resolutionButtonClass}
 | 
			
		||||
            submitButtonClass={resolutionButtonClass}
 | 
			
		||||
          />
 | 
			
		||||
        </Row>
 | 
			
		||||
      </Col>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { sortBy, partition, sum, uniq } from 'lodash'
 | 
			
		||||
import { sortBy, partition, sum } from 'lodash'
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
 | 
			
		||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
 | 
			
		||||
| 
						 | 
				
			
			@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
 | 
			
		|||
import { CreateAnswerPanel } from './create-answer-panel'
 | 
			
		||||
import { AnswerResolvePanel } from './answer-resolve-panel'
 | 
			
		||||
import { Spacer } from '../layout/spacer'
 | 
			
		||||
import { User } from 'common/user'
 | 
			
		||||
import { getOutcomeProbability } from 'common/calculate'
 | 
			
		||||
import { Answer } from 'common/answer'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
| 
						 | 
				
			
			@ -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 [resolveOption, setResolveOption] = useState<
 | 
			
		||||
| 
						 | 
				
			
			@ -67,12 +71,6 @@ export function AnswersPanel(props: {
 | 
			
		|||
 | 
			
		||||
  const chosenTotal = sum(Object.values(chosenAnswers))
 | 
			
		||||
 | 
			
		||||
  const answerItems = getAnswerItems(
 | 
			
		||||
    contract,
 | 
			
		||||
    losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
 | 
			
		||||
    user
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const onChoose = (answerId: string, prob: number) => {
 | 
			
		||||
    if (resolveOption === 'CHOOSE') {
 | 
			
		||||
      setChosenAnswers({ [answerId]: prob })
 | 
			
		||||
| 
						 | 
				
			
			@ -123,28 +121,26 @@ export function AnswersPanel(props: {
 | 
			
		|||
        ))}
 | 
			
		||||
 | 
			
		||||
      {!resolveOption && (
 | 
			
		||||
        <div className={clsx('flow-root pr-2 md:pr-0')}>
 | 
			
		||||
          <div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
 | 
			
		||||
            {answerItems.map((item) => (
 | 
			
		||||
              <div key={item.id} className={'relative pb-2'}>
 | 
			
		||||
                <div className="relative flex items-start space-x-3">
 | 
			
		||||
                  <OpenAnswer {...item} />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            ))}
 | 
			
		||||
            <Row className={'justify-end'}>
 | 
			
		||||
              {hasZeroBetAnswers && !showAllAnswers && (
 | 
			
		||||
                <Button
 | 
			
		||||
                  color={'gray-white'}
 | 
			
		||||
                  onClick={() => setShowAllAnswers(true)}
 | 
			
		||||
                  size={'md'}
 | 
			
		||||
                >
 | 
			
		||||
                  Show More
 | 
			
		||||
                </Button>
 | 
			
		||||
              )}
 | 
			
		||||
            </Row>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <Col
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            'gap-2 pr-2 md:pr-0',
 | 
			
		||||
            tradingAllowed(contract) ? '' : '-mb-6'
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {answerItems.map((item) => (
 | 
			
		||||
            <OpenAnswer key={item.id} answer={item} contract={contract} />
 | 
			
		||||
          ))}
 | 
			
		||||
          {hasZeroBetAnswers && !showAllAnswers && (
 | 
			
		||||
            <Button
 | 
			
		||||
              className="self-end"
 | 
			
		||||
              color="gray-white"
 | 
			
		||||
              onClick={() => setShowAllAnswers(true)}
 | 
			
		||||
              size="md"
 | 
			
		||||
            >
 | 
			
		||||
              Show More
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Col>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {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: {
 | 
			
		||||
  contract: FreeResponseContract | MultipleChoiceContract
 | 
			
		||||
  answer: Answer
 | 
			
		||||
  type: string
 | 
			
		||||
}) {
 | 
			
		||||
  const { answer, contract } = props
 | 
			
		||||
  const { username, avatarUrl, name, text } = answer
 | 
			
		||||
| 
						 | 
				
			
			@ -212,7 +182,7 @@ function OpenAnswer(props: {
 | 
			
		|||
  const [open, setOpen] = useState(false)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
 | 
			
		||||
    <Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
 | 
			
		||||
      <Modal open={open} setOpen={setOpen} position="center">
 | 
			
		||||
        <AnswerBetPanel
 | 
			
		||||
          answer={answer}
 | 
			
		||||
| 
						 | 
				
			
			@ -229,37 +199,30 @@ function OpenAnswer(props: {
 | 
			
		|||
      />
 | 
			
		||||
 | 
			
		||||
      <Row className="my-4 gap-3">
 | 
			
		||||
        <div className="px-1">
 | 
			
		||||
          <Avatar username={username} avatarUrl={avatarUrl} />
 | 
			
		||||
        </div>
 | 
			
		||||
        <Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
 | 
			
		||||
        <Col className="min-w-0 flex-1 lg:gap-1">
 | 
			
		||||
          <div className="text-sm text-gray-500">
 | 
			
		||||
            <UserLink username={username} name={name} /> answered
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <Col className="align-items justify-between gap-4 sm:flex-row">
 | 
			
		||||
            <span className="whitespace-pre-line text-lg">
 | 
			
		||||
              <Linkify text={text} />
 | 
			
		||||
            </span>
 | 
			
		||||
 | 
			
		||||
            <Row className="items-center justify-center gap-4">
 | 
			
		||||
              <div className={'align-items flex w-full justify-end gap-4 '}>
 | 
			
		||||
                <span
 | 
			
		||||
                  className={clsx(
 | 
			
		||||
                    'text-2xl',
 | 
			
		||||
                    tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
 | 
			
		||||
                  )}
 | 
			
		||||
                >
 | 
			
		||||
                  {probPercent}
 | 
			
		||||
                </span>
 | 
			
		||||
                <BuyButton
 | 
			
		||||
                  className={clsx(
 | 
			
		||||
                    'btn-sm flex-initial !px-6 sm:flex',
 | 
			
		||||
                    tradingAllowed(contract) ? '' : '!hidden'
 | 
			
		||||
                  )}
 | 
			
		||||
                  onClick={() => setOpen(true)}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            <Linkify className="whitespace-pre-line text-lg" text={text} />
 | 
			
		||||
            <Row className="align-items items-center justify-end gap-4">
 | 
			
		||||
              <span
 | 
			
		||||
                className={clsx(
 | 
			
		||||
                  'text-2xl',
 | 
			
		||||
                  tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
 | 
			
		||||
                )}
 | 
			
		||||
              >
 | 
			
		||||
                {probPercent}
 | 
			
		||||
              </span>
 | 
			
		||||
              <BuyButton
 | 
			
		||||
                className={clsx(
 | 
			
		||||
                  'btn-sm flex-initial !px-6 sm:flex',
 | 
			
		||||
                  tradingAllowed(contract) ? '' : '!hidden'
 | 
			
		||||
                )}
 | 
			
		||||
                onClick={() => setOpen(true)}
 | 
			
		||||
              />
 | 
			
		||||
            </Row>
 | 
			
		||||
          </Col>
 | 
			
		||||
        </Col>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie'
 | 
			
		|||
 | 
			
		||||
// Either we haven't looked up the logged in user yet (undefined), or we know
 | 
			
		||||
// the user is not logged in (null), or we know the user is logged in.
 | 
			
		||||
type AuthUser = undefined | null | UserAndPrivateUser
 | 
			
		||||
export type AuthUser = undefined | null | UserAndPrivateUser
 | 
			
		||||
 | 
			
		||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
 | 
			
		||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,13 +8,14 @@ export function Avatar(props: {
 | 
			
		|||
  username?: string
 | 
			
		||||
  avatarUrl?: string
 | 
			
		||||
  noLink?: boolean
 | 
			
		||||
  size?: number | 'xs' | 'sm'
 | 
			
		||||
  size?: number | 'xxs' | 'xs' | 'sm'
 | 
			
		||||
  className?: string
 | 
			
		||||
}) {
 | 
			
		||||
  const { username, noLink, size, className } = props
 | 
			
		||||
  const [avatarUrl, setAvatarUrl] = useState(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 onClick =
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +41,7 @@ export function Avatar(props: {
 | 
			
		|||
      style={{ maxWidth: `${s * 0.25}rem` }}
 | 
			
		||||
      src={avatarUrl}
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
      alt={username}
 | 
			
		||||
      alt={`${username ?? 'Unknown user'} avatar`}
 | 
			
		||||
      onError={() => {
 | 
			
		||||
        // If the image doesn't load, clear the avatarUrl to show the default
 | 
			
		||||
        // Mostly for localhost, when getting a 403 from googleusercontent
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										46
									
								
								web/components/award-bounty-button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								web/components/award-bounty-button.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,12 @@
 | 
			
		|||
import { useState } from 'react'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
 | 
			
		||||
import { SimpleBetPanel } from './bet-panel'
 | 
			
		||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
 | 
			
		||||
import { BuyPanel, SimpleBetPanel } from './bet-panel'
 | 
			
		||||
import {
 | 
			
		||||
  BinaryContract,
 | 
			
		||||
  CPMMBinaryContract,
 | 
			
		||||
  PseudoNumericContract,
 | 
			
		||||
} from 'common/contract'
 | 
			
		||||
import { Modal } from './layout/modal'
 | 
			
		||||
import { useUser } from 'web/hooks/use-user'
 | 
			
		||||
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 { Button } from 'web/components/button'
 | 
			
		||||
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 */
 | 
			
		||||
export default function BetButton(props: {
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +72,6 @@ export default function BetButton(props: {
 | 
			
		|||
        <SimpleBetPanel
 | 
			
		||||
          className={betPanelClassName}
 | 
			
		||||
          contract={contract}
 | 
			
		||||
          selected="YES"
 | 
			
		||||
          onBetSuccess={() => setOpen(false)}
 | 
			
		||||
          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>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,6 +43,10 @@ import { PlayMoneyDisclaimer } from './play-money-disclaimer'
 | 
			
		|||
import { isAndroid, isIOS } from 'web/lib/util/device'
 | 
			
		||||
import { WarningConfirmationButton } from './warning-confirmation-button'
 | 
			
		||||
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: {
 | 
			
		||||
  contract: CPMMBinaryContract | PseudoNumericContract
 | 
			
		||||
| 
						 | 
				
			
			@ -105,11 +109,10 @@ export function BetPanel(props: {
 | 
			
		|||
export function SimpleBetPanel(props: {
 | 
			
		||||
  contract: CPMMBinaryContract | PseudoNumericContract
 | 
			
		||||
  className?: string
 | 
			
		||||
  selected?: 'YES' | 'NO'
 | 
			
		||||
  hasShares?: boolean
 | 
			
		||||
  onBetSuccess?: () => void
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, className, selected, hasShares, onBetSuccess } = props
 | 
			
		||||
  const { contract, className, hasShares, onBetSuccess } = props
 | 
			
		||||
 | 
			
		||||
  const user = useUser()
 | 
			
		||||
  const [isLimitOrder, setIsLimitOrder] = useState(false)
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +142,6 @@ export function SimpleBetPanel(props: {
 | 
			
		|||
          contract={contract}
 | 
			
		||||
          user={user}
 | 
			
		||||
          unfilledBets={unfilledBets}
 | 
			
		||||
          selected={selected}
 | 
			
		||||
          onBuySuccess={onBetSuccess}
 | 
			
		||||
        />
 | 
			
		||||
        <LimitOrderPanel
 | 
			
		||||
| 
						 | 
				
			
			@ -162,38 +164,47 @@ export function SimpleBetPanel(props: {
 | 
			
		|||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function BuyPanel(props: {
 | 
			
		||||
export function BuyPanel(props: {
 | 
			
		||||
  contract: CPMMBinaryContract | PseudoNumericContract
 | 
			
		||||
  user: User | null | undefined
 | 
			
		||||
  unfilledBets: Bet[]
 | 
			
		||||
  hidden: boolean
 | 
			
		||||
  selected?: 'YES' | 'NO'
 | 
			
		||||
  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 isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
 | 
			
		||||
 | 
			
		||||
  const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
 | 
			
		||||
  const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
 | 
			
		||||
  const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
 | 
			
		||||
  const [betAmount, setBetAmount] = useState<number | undefined>(10)
 | 
			
		||||
  const [error, setError] = useState<string | undefined>()
 | 
			
		||||
  const [isSubmitting, setIsSubmitting] = useState(false)
 | 
			
		||||
  const [wasSubmitted, setWasSubmitted] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const [inputRef, focusAmountInput] = useFocus()
 | 
			
		||||
 | 
			
		||||
  function onBetChoice(choice: 'YES' | 'NO') {
 | 
			
		||||
    setOutcome(choice)
 | 
			
		||||
    setWasSubmitted(false)
 | 
			
		||||
 | 
			
		||||
    if (!isIOS() && !isAndroid()) {
 | 
			
		||||
      focusAmountInput()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
 | 
			
		||||
    if (outcome === choice) {
 | 
			
		||||
      setOutcome(undefined)
 | 
			
		||||
    } else {
 | 
			
		||||
      setOutcome(choice)
 | 
			
		||||
    }
 | 
			
		||||
    if (!isIOS() && !isAndroid()) {
 | 
			
		||||
      focusAmountInput()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function onBetChange(newAmount: number | undefined) {
 | 
			
		||||
    setWasSubmitted(false)
 | 
			
		||||
    setBetAmount(newAmount)
 | 
			
		||||
    if (!outcome) {
 | 
			
		||||
      setOutcome('YES')
 | 
			
		||||
| 
						 | 
				
			
			@ -214,9 +225,13 @@ function BuyPanel(props: {
 | 
			
		|||
      .then((r) => {
 | 
			
		||||
        console.log('placed bet. Result:', r)
 | 
			
		||||
        setIsSubmitting(false)
 | 
			
		||||
        setWasSubmitted(true)
 | 
			
		||||
        setBetAmount(undefined)
 | 
			
		||||
        if (onBuySuccess) onBuySuccess()
 | 
			
		||||
        else {
 | 
			
		||||
          toast('Trade submitted!', {
 | 
			
		||||
            icon: <CheckIcon className={'text-primary h-5 w-5'} />,
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch((e) => {
 | 
			
		||||
        if (e instanceof APIError) {
 | 
			
		||||
| 
						 | 
				
			
			@ -249,6 +264,7 @@ function BuyPanel(props: {
 | 
			
		|||
    unfilledBets as LimitBet[]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const [seeLimit, setSeeLimit] = useState(false)
 | 
			
		||||
  const resultProb = getCpmmProbability(newPool, newP)
 | 
			
		||||
  const probStayedSame =
 | 
			
		||||
    formatPercent(resultProb) === formatPercent(initialProb)
 | 
			
		||||
| 
						 | 
				
			
			@ -281,92 +297,132 @@ function BuyPanel(props: {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <Col className={hidden ? 'hidden' : ''}>
 | 
			
		||||
      <div className="my-3 text-left text-sm text-gray-500">
 | 
			
		||||
        {isPseudoNumeric ? 'Direction' : 'Outcome'}
 | 
			
		||||
      </div>
 | 
			
		||||
      <YesNoSelector
 | 
			
		||||
        className="mb-4"
 | 
			
		||||
        btnClassName="flex-1"
 | 
			
		||||
        selected={outcome}
 | 
			
		||||
        onSelect={(choice) => onBetChoice(choice)}
 | 
			
		||||
        onSelect={(choice) => {
 | 
			
		||||
          if (mobileView) {
 | 
			
		||||
            mobileOnBetChoice(choice)
 | 
			
		||||
          } else {
 | 
			
		||||
            onBetChoice(choice)
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        isPseudoNumeric={isPseudoNumeric}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <Row className="my-3 justify-between text-left text-sm text-gray-500">
 | 
			
		||||
        Amount
 | 
			
		||||
        <span className={'xl:hidden'}>
 | 
			
		||||
          Balance: {formatMoney(user?.balance ?? 0)}
 | 
			
		||||
        </span>
 | 
			
		||||
      </Row>
 | 
			
		||||
 | 
			
		||||
      <BuyAmountInput
 | 
			
		||||
        inputClassName="w-full max-w-none"
 | 
			
		||||
        amount={betAmount}
 | 
			
		||||
        onChange={onBetChange}
 | 
			
		||||
        error={error}
 | 
			
		||||
        setError={setError}
 | 
			
		||||
        disabled={isSubmitting}
 | 
			
		||||
        inputRef={inputRef}
 | 
			
		||||
        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} />
 | 
			
		||||
 | 
			
		||||
      {user && (
 | 
			
		||||
        <WarningConfirmationButton
 | 
			
		||||
          warning={warning}
 | 
			
		||||
          onSubmit={submitBet}
 | 
			
		||||
          isSubmitting={isSubmitting}
 | 
			
		||||
          disabled={!!betDisabled}
 | 
			
		||||
          openModalButtonClass={clsx(
 | 
			
		||||
            'btn mb-2 flex-1',
 | 
			
		||||
            betDisabled
 | 
			
		||||
              ? 'btn-disabled'
 | 
			
		||||
      <Col
 | 
			
		||||
        className={clsx(
 | 
			
		||||
          mobileView
 | 
			
		||||
            ? outcome === 'NO'
 | 
			
		||||
              ? 'bg-red-25'
 | 
			
		||||
              : outcome === 'YES'
 | 
			
		||||
              ? 'btn-primary'
 | 
			
		||||
              : 'border-none bg-red-400 hover:bg-red-500'
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
              ? '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 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>
 | 
			
		||||
 | 
			
		||||
      {wasSubmitted && <div className="mt-4">Trade submitted!</div>}
 | 
			
		||||
        <BuyAmountInput
 | 
			
		||||
          inputClassName="w-full max-w-none"
 | 
			
		||||
          amount={betAmount}
 | 
			
		||||
          onChange={onBetChange}
 | 
			
		||||
          error={error}
 | 
			
		||||
          setError={setError}
 | 
			
		||||
          disabled={isSubmitting}
 | 
			
		||||
          inputRef={inputRef}
 | 
			
		||||
          showSliderOnMobile
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <Spacer h={8} />
 | 
			
		||||
 | 
			
		||||
        {user && (
 | 
			
		||||
          <WarningConfirmationButton
 | 
			
		||||
            marketType="binary"
 | 
			
		||||
            amount={betAmount}
 | 
			
		||||
            warning={warning}
 | 
			
		||||
            onSubmit={submitBet}
 | 
			
		||||
            isSubmitting={isSubmitting}
 | 
			
		||||
            disabled={!!betDisabled || outcome === undefined}
 | 
			
		||||
            size="xl"
 | 
			
		||||
            color={outcome === 'NO' ? 'red' : 'green'}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <button
 | 
			
		||||
          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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -389,7 +445,6 @@ function LimitOrderPanel(props: {
 | 
			
		|||
  const betChoice = 'YES'
 | 
			
		||||
  const [error, setError] = useState<string | undefined>()
 | 
			
		||||
  const [isSubmitting, setIsSubmitting] = useState(false)
 | 
			
		||||
  const [wasSubmitted, setWasSubmitted] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const rangeError =
 | 
			
		||||
    lowLimitProb !== undefined &&
 | 
			
		||||
| 
						 | 
				
			
			@ -437,7 +492,6 @@ function LimitOrderPanel(props: {
 | 
			
		|||
  const noAmount = shares * (1 - (noLimitProb ?? 0))
 | 
			
		||||
 | 
			
		||||
  function onBetChange(newAmount: number | undefined) {
 | 
			
		||||
    setWasSubmitted(false)
 | 
			
		||||
    setBetAmount(newAmount)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -482,7 +536,6 @@ function LimitOrderPanel(props: {
 | 
			
		|||
      .then((r) => {
 | 
			
		||||
        console.log('placed bet. Result:', r)
 | 
			
		||||
        setIsSubmitting(false)
 | 
			
		||||
        setWasSubmitted(true)
 | 
			
		||||
        setBetAmount(undefined)
 | 
			
		||||
        setLowLimitProb(undefined)
 | 
			
		||||
        setHighLimitProb(undefined)
 | 
			
		||||
| 
						 | 
				
			
			@ -718,8 +771,6 @@ function LimitOrderPanel(props: {
 | 
			
		|||
            : `Submit order${hasTwoBets ? 's' : ''}`}
 | 
			
		||||
        </button>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {wasSubmitted && <div className="mt-4">Order submitted!</div>}
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -866,11 +917,7 @@ export function SellPanel(props: {
 | 
			
		|||
    <>
 | 
			
		||||
      <AmountInput
 | 
			
		||||
        amount={
 | 
			
		||||
          amount
 | 
			
		||||
            ? Math.round(amount) === 0
 | 
			
		||||
              ? 0
 | 
			
		||||
              : Math.floor(amount)
 | 
			
		||||
            : undefined
 | 
			
		||||
          amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
 | 
			
		||||
        }
 | 
			
		||||
        onChange={onAmountChange}
 | 
			
		||||
        label="Qty"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										120
									
								
								web/components/bet-summary.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								web/components/bet-summary.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,7 +2,6 @@ import Link from 'next/link'
 | 
			
		|||
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
 | 
			
		||||
import dayjs from 'dayjs'
 | 
			
		||||
import { useMemo, useState } from 'react'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
 | 
			
		||||
 | 
			
		||||
import { Bet } from 'web/lib/firebase/bets'
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +21,7 @@ import {
 | 
			
		|||
import { Row } from './layout/row'
 | 
			
		||||
import { sellBet } from 'web/lib/firebase/api'
 | 
			
		||||
import { ConfirmationButton } from './confirmation-button'
 | 
			
		||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
 | 
			
		||||
import { OutcomeLabel } from './outcome-label'
 | 
			
		||||
import { LoadingIndicator } from './loading-indicator'
 | 
			
		||||
import { SiteLink } from './site-link'
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -38,14 +37,19 @@ import { NumericContract } from 'common/contract'
 | 
			
		|||
import { formatNumericProbability } from 'common/pseudo-numeric'
 | 
			
		||||
import { useUser } from 'web/hooks/use-user'
 | 
			
		||||
import { useUserBets } from 'web/hooks/use-user-bets'
 | 
			
		||||
import { SellSharesModal } from './sell-modal'
 | 
			
		||||
import { useUnfilledBets } from 'web/hooks/use-bets'
 | 
			
		||||
import { LimitBet } from 'common/bet'
 | 
			
		||||
import { floatingEqual } from 'common/util/math'
 | 
			
		||||
import { Pagination } from './pagination'
 | 
			
		||||
import { LimitOrderTable } from './limit-bets'
 | 
			
		||||
import { UserLink } from 'web/components/user-link'
 | 
			
		||||
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 BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
 | 
			
		||||
| 
						 | 
				
			
			@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
 | 
			
		|||
    return contractList ? keyBy(contractList, 'id') : undefined
 | 
			
		||||
  }, [contractList])
 | 
			
		||||
 | 
			
		||||
  const [sort, setSort] = useState<BetSort>('newest')
 | 
			
		||||
  const [filter, setFilter] = useState<BetFilter>('open')
 | 
			
		||||
  const [sort, setSort] = usePersistentState<BetSort>('newest', {
 | 
			
		||||
    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 start = page * CONTRACTS_PER_PAGE
 | 
			
		||||
  const end = start + CONTRACTS_PER_PAGE
 | 
			
		||||
| 
						 | 
				
			
			@ -155,34 +165,25 @@ export function BetsList(props: { user: User }) {
 | 
			
		|||
    (c) => contractsMetrics[c.id].netPayout
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const totalPnl = user.profitCached.allTime
 | 
			
		||||
  const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
 | 
			
		||||
  const investedProfitPercent =
 | 
			
		||||
    ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Col>
 | 
			
		||||
      <Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
 | 
			
		||||
        <Row className="gap-8">
 | 
			
		||||
          <Col>
 | 
			
		||||
            <div className="text-sm text-gray-500">Investment value</div>
 | 
			
		||||
            <div className="text-lg">
 | 
			
		||||
              {formatMoney(currentNetInvestment)}{' '}
 | 
			
		||||
              <ProfitBadge profitPercent={investedProfitPercent} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </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="justify-between gap-4 sm:flex-row">
 | 
			
		||||
        <Col>
 | 
			
		||||
          <div className="text-greyscale-6 text-xs sm:text-sm">
 | 
			
		||||
            Investment value
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="text-lg">
 | 
			
		||||
            {formatMoney(currentNetInvestment)}{' '}
 | 
			
		||||
            <ProfitBadge profitPercent={investedProfitPercent} />
 | 
			
		||||
          </div>
 | 
			
		||||
        </Col>
 | 
			
		||||
 | 
			
		||||
        <Row className="gap-8">
 | 
			
		||||
        <Row className="gap-2">
 | 
			
		||||
          <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}
 | 
			
		||||
            onChange={(e) => setFilter(e.target.value as BetFilter)}
 | 
			
		||||
          >
 | 
			
		||||
| 
						 | 
				
			
			@ -195,7 +196,7 @@ export function BetsList(props: { user: User }) {
 | 
			
		|||
          </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}
 | 
			
		||||
            onChange={(e) => setSort(e.target.value as BetSort)}
 | 
			
		||||
          >
 | 
			
		||||
| 
						 | 
				
			
			@ -205,7 +206,7 @@ export function BetsList(props: { user: User }) {
 | 
			
		|||
            <option value="closeTime">Close date</option>
 | 
			
		||||
          </select>
 | 
			
		||||
        </Row>
 | 
			
		||||
      </Col>
 | 
			
		||||
      </Row>
 | 
			
		||||
 | 
			
		||||
      <Col className="mt-6 divide-y">
 | 
			
		||||
        {displayedContracts.length === 0 ? (
 | 
			
		||||
| 
						 | 
				
			
			@ -346,8 +347,7 @@ function ContractBets(props: {
 | 
			
		|||
          <BetsSummary
 | 
			
		||||
            className="mt-8 mr-5 flex-1 sm:mr-8"
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            bets={bets}
 | 
			
		||||
            isYourBets={isYourBets}
 | 
			
		||||
            userBets={bets}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {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: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  bets: Bet[]
 | 
			
		||||
| 
						 | 
				
			
			@ -610,18 +491,24 @@ function BetRow(props: {
 | 
			
		|||
  const isNumeric = outcomeType === '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 ? (
 | 
			
		||||
    'ANTE'
 | 
			
		||||
  ) : saleAmount !== undefined ? (
 | 
			
		||||
    <>{formatMoney(saleAmount)} (sold)</>
 | 
			
		||||
  ) : saleBet ? (
 | 
			
		||||
    <>{formatMoney(payout)} (sold)</>
 | 
			
		||||
  ) : (
 | 
			
		||||
    formatMoney(
 | 
			
		||||
      isResolved
 | 
			
		||||
        ? resolvedPayout(contract, bet)
 | 
			
		||||
        : calculateSaleAmount(contract, bet, unfilledBets)
 | 
			
		||||
    )
 | 
			
		||||
    formatMoney(payout)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const payoutIfChosenDisplay =
 | 
			
		||||
| 
						 | 
				
			
			@ -722,8 +609,8 @@ function SellButton(props: {
 | 
			
		|||
  return (
 | 
			
		||||
    <ConfirmationButton
 | 
			
		||||
      openModalBtn={{
 | 
			
		||||
        className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
 | 
			
		||||
        label: 'Sell',
 | 
			
		||||
        disabled: isSubmitting,
 | 
			
		||||
      }}
 | 
			
		||||
      submitBtn={{ className: 'btn-primary', label: 'Sell' }}
 | 
			
		||||
      onSubmit={async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -753,30 +640,3 @@ function SellButton(props: {
 | 
			
		|||
    </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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,20 +46,26 @@ export function Button(props: {
 | 
			
		|||
    <button
 | 
			
		||||
      type={type}
 | 
			
		||||
      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,
 | 
			
		||||
        color === 'green' && 'btn-primary text-white',
 | 
			
		||||
        color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
 | 
			
		||||
        color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
 | 
			
		||||
        color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
 | 
			
		||||
        color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
 | 
			
		||||
        color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
 | 
			
		||||
        color === 'green' &&
 | 
			
		||||
          'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
 | 
			
		||||
        color === 'red' &&
 | 
			
		||||
          'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
 | 
			
		||||
        color === 'yellow' &&
 | 
			
		||||
          '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' &&
 | 
			
		||||
          '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' &&
 | 
			
		||||
          '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' &&
 | 
			
		||||
          'text-highlight-blue border-none shadow-none',
 | 
			
		||||
          'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										86
									
								
								web/components/charts/contract/binary.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								web/components/charts/contract/binary.tsx
									
									
									
									
									
										Normal 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
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										224
									
								
								web/components/charts/contract/choice.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								web/components/charts/contract/choice.tsx
									
									
									
									
									
										Normal 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
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								web/components/charts/contract/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/components/charts/contract/index.tsx
									
									
									
									
									
										Normal 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,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										59
									
								
								web/components/charts/contract/numeric.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								web/components/charts/contract/numeric.tsx
									
									
									
									
									
										Normal 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}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										107
									
								
								web/components/charts/contract/pseudo-numeric.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								web/components/charts/contract/pseudo-numeric.tsx
									
									
									
									
									
										Normal 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}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										256
									
								
								web/components/charts/generic-charts.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								web/components/charts/generic-charts.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										359
									
								
								web/components/charts/helpers.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										359
									
								
								web/components/charts/helpers.tsx
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								web/components/charts/stats.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								web/components/charts/stats.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ import { Row } from './layout/row'
 | 
			
		|||
import { LoadingIndicator } from './loading-indicator'
 | 
			
		||||
 | 
			
		||||
export function CommentInput(props: {
 | 
			
		||||
  replyToUser?: { id: string; username: string }
 | 
			
		||||
  replyTo?: { id: string; username: string }
 | 
			
		||||
  // Reply to a free response answer
 | 
			
		||||
  parentAnswerOutcome?: string
 | 
			
		||||
  // Reply to another comment
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ export function CommentInput(props: {
 | 
			
		|||
  onSubmitComment?: (editor: Editor) => void
 | 
			
		||||
  className?: string
 | 
			
		||||
}) {
 | 
			
		||||
  const { parentAnswerOutcome, parentCommentId, replyToUser, onSubmitComment } =
 | 
			
		||||
  const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
 | 
			
		||||
    props
 | 
			
		||||
  const user = useUser()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -55,7 +55,7 @@ export function CommentInput(props: {
 | 
			
		|||
        <CommentInputTextArea
 | 
			
		||||
          editor={editor}
 | 
			
		||||
          upload={upload}
 | 
			
		||||
          replyToUser={replyToUser}
 | 
			
		||||
          replyTo={replyTo}
 | 
			
		||||
          user={user}
 | 
			
		||||
          submitComment={submitComment}
 | 
			
		||||
          isSubmitting={isSubmitting}
 | 
			
		||||
| 
						 | 
				
			
			@ -67,14 +67,13 @@ export function CommentInput(props: {
 | 
			
		|||
 | 
			
		||||
export function CommentInputTextArea(props: {
 | 
			
		||||
  user: User | undefined | null
 | 
			
		||||
  replyToUser?: { id: string; username: string }
 | 
			
		||||
  replyTo?: { id: string; username: string }
 | 
			
		||||
  editor: Editor | null
 | 
			
		||||
  upload: Parameters<typeof TextEditor>[0]['upload']
 | 
			
		||||
  submitComment: () => void
 | 
			
		||||
  isSubmitting: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { user, editor, upload, submitComment, isSubmitting, replyToUser } =
 | 
			
		||||
    props
 | 
			
		||||
  const { user, editor, upload, submitComment, isSubmitting, replyTo } = props
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    editor?.setEditable(!isSubmitting)
 | 
			
		||||
  }, [isSubmitting, editor])
 | 
			
		||||
| 
						 | 
				
			
			@ -108,12 +107,12 @@ export function CommentInputTextArea(props: {
 | 
			
		|||
      },
 | 
			
		||||
    })
 | 
			
		||||
    // insert at mention and focus
 | 
			
		||||
    if (replyToUser) {
 | 
			
		||||
    if (replyTo) {
 | 
			
		||||
      editor
 | 
			
		||||
        .chain()
 | 
			
		||||
        .setContent({
 | 
			
		||||
          type: 'mention',
 | 
			
		||||
          attrs: { label: replyToUser.username, id: replyToUser.id },
 | 
			
		||||
          attrs: { label: replyTo.username, id: replyTo.id },
 | 
			
		||||
        })
 | 
			
		||||
        .insertContent(' ')
 | 
			
		||||
        .focus()
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +126,7 @@ export function CommentInputTextArea(props: {
 | 
			
		|||
      <TextEditor editor={editor} upload={upload}>
 | 
			
		||||
        {user && !isSubmitting && (
 | 
			
		||||
          <button
 | 
			
		||||
            className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
 | 
			
		||||
            className="px-2 text-gray-400 hover:text-gray-500 disabled:bg-inherit disabled:text-gray-300"
 | 
			
		||||
            disabled={!editor || editor.isEmpty}
 | 
			
		||||
            onClick={submit}
 | 
			
		||||
          >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import clsx from 'clsx'
 | 
			
		||||
import { ReactNode, useState } from 'react'
 | 
			
		||||
import { Button, ColorType, SizeType } from './button'
 | 
			
		||||
import { Col } from './layout/col'
 | 
			
		||||
import { Modal } from './layout/modal'
 | 
			
		||||
import { Row } from './layout/row'
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +10,9 @@ export function ConfirmationButton(props: {
 | 
			
		|||
    label: string
 | 
			
		||||
    icon?: JSX.Element
 | 
			
		||||
    className?: string
 | 
			
		||||
    color?: ColorType
 | 
			
		||||
    size?: SizeType
 | 
			
		||||
    disabled?: boolean
 | 
			
		||||
  }
 | 
			
		||||
  cancelBtn?: {
 | 
			
		||||
    label?: string
 | 
			
		||||
| 
						 | 
				
			
			@ -68,13 +72,16 @@ export function ConfirmationButton(props: {
 | 
			
		|||
          </Row>
 | 
			
		||||
        </Col>
 | 
			
		||||
      </Modal>
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx('btn', openModalBtn.className)}
 | 
			
		||||
      <Button
 | 
			
		||||
        className={clsx(openModalBtn.className)}
 | 
			
		||||
        onClick={() => updateOpen(true)}
 | 
			
		||||
        disabled={openModalBtn.disabled}
 | 
			
		||||
        color={openModalBtn.color}
 | 
			
		||||
        size={openModalBtn.size}
 | 
			
		||||
      >
 | 
			
		||||
        {openModalBtn.icon}
 | 
			
		||||
        {openModalBtn.label}
 | 
			
		||||
      </div>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -84,18 +91,25 @@ export function ResolveConfirmationButton(props: {
 | 
			
		|||
  isSubmitting: boolean
 | 
			
		||||
  openModalButtonClass?: string
 | 
			
		||||
  submitButtonClass?: string
 | 
			
		||||
  color?: ColorType
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } =
 | 
			
		||||
    props
 | 
			
		||||
  const {
 | 
			
		||||
    onResolve,
 | 
			
		||||
    isSubmitting,
 | 
			
		||||
    openModalButtonClass,
 | 
			
		||||
    submitButtonClass,
 | 
			
		||||
    color,
 | 
			
		||||
    disabled,
 | 
			
		||||
  } = props
 | 
			
		||||
  return (
 | 
			
		||||
    <ConfirmationButton
 | 
			
		||||
      openModalBtn={{
 | 
			
		||||
        className: clsx(
 | 
			
		||||
          'border-none self-start',
 | 
			
		||||
          openModalButtonClass,
 | 
			
		||||
          isSubmitting && 'btn-disabled loading'
 | 
			
		||||
        ),
 | 
			
		||||
        className: clsx('border-none self-start', openModalButtonClass),
 | 
			
		||||
        label: 'Resolve',
 | 
			
		||||
        color: color,
 | 
			
		||||
        disabled: isSubmitting || disabled,
 | 
			
		||||
        size: 'xl',
 | 
			
		||||
      }}
 | 
			
		||||
      cancelBtn={{
 | 
			
		||||
        label: 'Back',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,13 +3,17 @@ import { SearchOptions } from '@algolia/client-search'
 | 
			
		|||
import { useRouter } from 'next/router'
 | 
			
		||||
import { Contract } from 'common/contract'
 | 
			
		||||
import { PAST_BETS, User } from 'common/user'
 | 
			
		||||
import {
 | 
			
		||||
  ContractHighlightOptions,
 | 
			
		||||
  ContractsGrid,
 | 
			
		||||
} from './contract/contracts-grid'
 | 
			
		||||
import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
 | 
			
		||||
import { ShowTime } from './contract/contract-details'
 | 
			
		||||
import { Row } from './layout/row'
 | 
			
		||||
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
 | 
			
		||||
import {
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useLayoutEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  ReactNode,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react'
 | 
			
		||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
 | 
			
		||||
import { useFollows } from 'web/hooks/use-follows'
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,22 +36,26 @@ import {
 | 
			
		|||
  searchClient,
 | 
			
		||||
  searchIndexName,
 | 
			
		||||
} from 'web/lib/service/algolia'
 | 
			
		||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
 | 
			
		||||
import { AdjustmentsIcon } from '@heroicons/react/solid'
 | 
			
		||||
import { Button } from './button'
 | 
			
		||||
import { Modal } from './layout/modal'
 | 
			
		||||
import { Title } from './title'
 | 
			
		||||
 | 
			
		||||
export const SORTS = [
 | 
			
		||||
  { label: 'Newest', value: 'newest' },
 | 
			
		||||
  { label: 'Trending', value: 'score' },
 | 
			
		||||
  { label: `Most traded`, value: 'most-traded' },
 | 
			
		||||
  { label: 'Daily trending', value: 'daily-score' },
 | 
			
		||||
  { label: '24h volume', value: '24-hour-vol' },
 | 
			
		||||
  { label: '24h change', value: 'prob-change-day' },
 | 
			
		||||
  { label: 'Last updated', value: 'last-updated' },
 | 
			
		||||
  { label: 'Subsidy', value: 'liquidity' },
 | 
			
		||||
  { label: 'Close date', value: 'close-date' },
 | 
			
		||||
  { label: 'Closing soon', value: 'close-date' },
 | 
			
		||||
  { label: 'Resolve date', value: 'resolve-date' },
 | 
			
		||||
  { label: 'Highest %', value: 'prob-descending' },
 | 
			
		||||
  { label: 'Lowest %', value: 'prob-ascending' },
 | 
			
		||||
] as const
 | 
			
		||||
 | 
			
		||||
export type Sort = typeof SORTS[number]['value']
 | 
			
		||||
export const PROB_SORTS = ['prob-descending', 'prob-ascending']
 | 
			
		||||
 | 
			
		||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -71,18 +79,20 @@ export function ContractSearch(props: {
 | 
			
		|||
  defaultFilter?: filter
 | 
			
		||||
  defaultPill?: string
 | 
			
		||||
  additionalFilter?: AdditionalFilter
 | 
			
		||||
  highlightOptions?: ContractHighlightOptions
 | 
			
		||||
  highlightOptions?: CardHighlightOptions
 | 
			
		||||
  onContractClick?: (contract: Contract) => void
 | 
			
		||||
  hideOrderSelector?: boolean
 | 
			
		||||
  cardUIOptions?: {
 | 
			
		||||
    hideGroupLink?: boolean
 | 
			
		||||
    hideQuickBet?: boolean
 | 
			
		||||
    noLinkAvatar?: boolean
 | 
			
		||||
    showProbChange?: boolean
 | 
			
		||||
  }
 | 
			
		||||
  headerClassName?: string
 | 
			
		||||
  persistPrefix?: string
 | 
			
		||||
  useQueryUrlParam?: boolean
 | 
			
		||||
  isWholePage?: boolean
 | 
			
		||||
  includeProbSorts?: boolean
 | 
			
		||||
  noControls?: boolean
 | 
			
		||||
  maxResults?: number
 | 
			
		||||
  renderContracts?: (
 | 
			
		||||
| 
						 | 
				
			
			@ -90,6 +100,7 @@ export function ContractSearch(props: {
 | 
			
		|||
    loadMore: () => void
 | 
			
		||||
  ) => ReactNode
 | 
			
		||||
  autoFocus?: boolean
 | 
			
		||||
  profile?: boolean | undefined
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    user,
 | 
			
		||||
| 
						 | 
				
			
			@ -104,11 +115,13 @@ export function ContractSearch(props: {
 | 
			
		|||
    headerClassName,
 | 
			
		||||
    persistPrefix,
 | 
			
		||||
    useQueryUrlParam,
 | 
			
		||||
    includeProbSorts,
 | 
			
		||||
    isWholePage,
 | 
			
		||||
    noControls,
 | 
			
		||||
    maxResults,
 | 
			
		||||
    renderContracts,
 | 
			
		||||
    autoFocus,
 | 
			
		||||
    profile,
 | 
			
		||||
  } = props
 | 
			
		||||
 | 
			
		||||
  const [state, setState] = usePersistentState(
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +129,7 @@ export function ContractSearch(props: {
 | 
			
		|||
      numPages: 1,
 | 
			
		||||
      pages: [] as Contract[][],
 | 
			
		||||
      showTime: null as ShowTime | null,
 | 
			
		||||
      showProbChange: false,
 | 
			
		||||
    },
 | 
			
		||||
    !persistPrefix
 | 
			
		||||
      ? undefined
 | 
			
		||||
| 
						 | 
				
			
			@ -169,8 +183,9 @@ export function ContractSearch(props: {
 | 
			
		|||
        const newPage = results.hits as any as Contract[]
 | 
			
		||||
        const showTime =
 | 
			
		||||
          sort === 'close-date' || sort === 'resolve-date' ? sort : null
 | 
			
		||||
        const showProbChange = sort === 'daily-score'
 | 
			
		||||
        const pages = freshQuery ? [newPage] : [...state.pages, newPage]
 | 
			
		||||
        setState({ numPages: results.nbPages, pages, showTime })
 | 
			
		||||
        setState({ numPages: results.nbPages, pages, showTime, showProbChange })
 | 
			
		||||
        if (freshQuery && isWholePage) window.scrollTo(0, 0)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -188,6 +203,12 @@ export function ContractSearch(props: {
 | 
			
		|||
    }, 100)
 | 
			
		||||
  ).current
 | 
			
		||||
 | 
			
		||||
  const updatedCardUIOptions = useMemo(() => {
 | 
			
		||||
    if (cardUIOptions?.showProbChange === undefined && state.showProbChange)
 | 
			
		||||
      return { ...cardUIOptions, showProbChange: true }
 | 
			
		||||
    return cardUIOptions
 | 
			
		||||
  }, [cardUIOptions, state.showProbChange])
 | 
			
		||||
 | 
			
		||||
  const contracts = state.pages
 | 
			
		||||
    .flat()
 | 
			
		||||
    .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
 | 
			
		||||
| 
						 | 
				
			
			@ -209,6 +230,7 @@ export function ContractSearch(props: {
 | 
			
		|||
        persistPrefix={persistPrefix}
 | 
			
		||||
        hideOrderSelector={hideOrderSelector}
 | 
			
		||||
        useQueryUrlParam={useQueryUrlParam}
 | 
			
		||||
        includeProbSorts={includeProbSorts}
 | 
			
		||||
        user={user}
 | 
			
		||||
        onSearchParametersChanged={onSearchParametersChanged}
 | 
			
		||||
        noControls={noControls}
 | 
			
		||||
| 
						 | 
				
			
			@ -216,6 +238,10 @@ export function ContractSearch(props: {
 | 
			
		|||
      />
 | 
			
		||||
      {renderContracts ? (
 | 
			
		||||
        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
 | 
			
		||||
          contracts={renderedContracts}
 | 
			
		||||
| 
						 | 
				
			
			@ -223,7 +249,7 @@ export function ContractSearch(props: {
 | 
			
		|||
          showTime={state.showTime ?? undefined}
 | 
			
		||||
          onContractClick={onContractClick}
 | 
			
		||||
          highlightOptions={highlightOptions}
 | 
			
		||||
          cardUIOptions={cardUIOptions}
 | 
			
		||||
          cardUIOptions={updatedCardUIOptions}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Col>
 | 
			
		||||
| 
						 | 
				
			
			@ -238,6 +264,7 @@ function ContractSearchControls(props: {
 | 
			
		|||
  additionalFilter?: AdditionalFilter
 | 
			
		||||
  persistPrefix?: string
 | 
			
		||||
  hideOrderSelector?: boolean
 | 
			
		||||
  includeProbSorts?: boolean
 | 
			
		||||
  onSearchParametersChanged: (params: SearchParameters) => void
 | 
			
		||||
  useQueryUrlParam?: boolean
 | 
			
		||||
  user?: User | null
 | 
			
		||||
| 
						 | 
				
			
			@ -257,6 +284,7 @@ function ContractSearchControls(props: {
 | 
			
		|||
    user,
 | 
			
		||||
    noControls,
 | 
			
		||||
    autoFocus,
 | 
			
		||||
    includeProbSorts,
 | 
			
		||||
  } = props
 | 
			
		||||
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
| 
						 | 
				
			
			@ -270,6 +298,8 @@ function ContractSearchControls(props: {
 | 
			
		|||
        }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const isMobile = useIsMobile()
 | 
			
		||||
 | 
			
		||||
  const sortKey = `${persistPrefix}-search-sort`
 | 
			
		||||
  const savedSort = safeLocalStorage()?.getItem(sortKey)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -415,30 +445,33 @@ function ContractSearchControls(props: {
 | 
			
		|||
          className="input input-bordered w-full"
 | 
			
		||||
          autoFocus={autoFocus}
 | 
			
		||||
        />
 | 
			
		||||
        {!query && (
 | 
			
		||||
          <select
 | 
			
		||||
            className="select select-bordered"
 | 
			
		||||
            value={filter}
 | 
			
		||||
            onChange={(e) => selectFilter(e.target.value as filter)}
 | 
			
		||||
          >
 | 
			
		||||
            <option value="open">Open</option>
 | 
			
		||||
            <option value="closed">Closed</option>
 | 
			
		||||
            <option value="resolved">Resolved</option>
 | 
			
		||||
            <option value="all">All</option>
 | 
			
		||||
          </select>
 | 
			
		||||
        {!isMobile && (
 | 
			
		||||
          <SearchFilters
 | 
			
		||||
            filter={filter}
 | 
			
		||||
            selectFilter={selectFilter}
 | 
			
		||||
            hideOrderSelector={hideOrderSelector}
 | 
			
		||||
            selectSort={selectSort}
 | 
			
		||||
            sort={sort}
 | 
			
		||||
            className={'flex flex-row gap-2'}
 | 
			
		||||
            includeProbSorts={includeProbSorts}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {!hideOrderSelector && !query && (
 | 
			
		||||
          <select
 | 
			
		||||
            className="select select-bordered"
 | 
			
		||||
            value={sort}
 | 
			
		||||
            onChange={(e) => selectSort(e.target.value as Sort)}
 | 
			
		||||
          >
 | 
			
		||||
            {SORTS.map((option) => (
 | 
			
		||||
              <option key={option.value} value={option.value}>
 | 
			
		||||
                {option.label}
 | 
			
		||||
              </option>
 | 
			
		||||
            ))}
 | 
			
		||||
          </select>
 | 
			
		||||
        {isMobile && (
 | 
			
		||||
          <>
 | 
			
		||||
            <MobileSearchBar
 | 
			
		||||
              children={
 | 
			
		||||
                <SearchFilters
 | 
			
		||||
                  filter={filter}
 | 
			
		||||
                  selectFilter={selectFilter}
 | 
			
		||||
                  hideOrderSelector={hideOrderSelector}
 | 
			
		||||
                  selectSort={selectSort}
 | 
			
		||||
                  sort={sort}
 | 
			
		||||
                  className={'flex flex-col gap-4'}
 | 
			
		||||
                  includeProbSorts={includeProbSorts}
 | 
			
		||||
                />
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
      </Row>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -481,3 +514,78 @@ function ContractSearchControls(props: {
 | 
			
		|||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SearchFilters(props: {
 | 
			
		||||
  filter: string
 | 
			
		||||
  selectFilter: (newFilter: filter) => void
 | 
			
		||||
  hideOrderSelector: boolean | undefined
 | 
			
		||||
  selectSort: (newSort: Sort) => void
 | 
			
		||||
  sort: string
 | 
			
		||||
  className?: string
 | 
			
		||||
  includeProbSorts?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    filter,
 | 
			
		||||
    selectFilter,
 | 
			
		||||
    hideOrderSelector,
 | 
			
		||||
    selectSort,
 | 
			
		||||
    sort,
 | 
			
		||||
    className,
 | 
			
		||||
    includeProbSorts,
 | 
			
		||||
  } = props
 | 
			
		||||
 | 
			
		||||
  const sorts = includeProbSorts
 | 
			
		||||
    ? SORTS
 | 
			
		||||
    : SORTS.filter((sort) => !PROB_SORTS.includes(sort.value))
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={className}>
 | 
			
		||||
      <select
 | 
			
		||||
        className="select select-bordered"
 | 
			
		||||
        value={filter}
 | 
			
		||||
        onChange={(e) => selectFilter(e.target.value as filter)}
 | 
			
		||||
      >
 | 
			
		||||
        <option value="open">Open</option>
 | 
			
		||||
        <option value="closed">Closed</option>
 | 
			
		||||
        <option value="resolved">Resolved</option>
 | 
			
		||||
        <option value="all">All</option>
 | 
			
		||||
      </select>
 | 
			
		||||
      {!hideOrderSelector && (
 | 
			
		||||
        <select
 | 
			
		||||
          className="select select-bordered"
 | 
			
		||||
          value={sort}
 | 
			
		||||
          onChange={(e) => selectSort(e.target.value as Sort)}
 | 
			
		||||
        >
 | 
			
		||||
          {sorts.map((option) => (
 | 
			
		||||
            <option key={option.value} value={option.value}>
 | 
			
		||||
              {option.label}
 | 
			
		||||
            </option>
 | 
			
		||||
          ))}
 | 
			
		||||
        </select>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function MobileSearchBar(props: { children: ReactNode }) {
 | 
			
		||||
  const { children } = props
 | 
			
		||||
  const [openFilters, setOpenFilters] = useState(false)
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button color="gray-white" onClick={() => setOpenFilters(true)}>
 | 
			
		||||
        <AdjustmentsIcon className="my-auto h-7" />
 | 
			
		||||
      </Button>
 | 
			
		||||
      <Modal
 | 
			
		||||
        open={openFilters}
 | 
			
		||||
        setOpen={setOpenFilters}
 | 
			
		||||
        position="top"
 | 
			
		||||
        className="rounded-lg bg-white px-4 pb-4"
 | 
			
		||||
      >
 | 
			
		||||
        <Col>
 | 
			
		||||
          <Title text="Filter Markets" />
 | 
			
		||||
          {children}
 | 
			
		||||
        </Col>
 | 
			
		||||
      </Modal>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
 | 
			
		|||
              noLinkAvatar: true,
 | 
			
		||||
            }}
 | 
			
		||||
            highlightOptions={{
 | 
			
		||||
              contractIds: contracts.map((c) => c.id),
 | 
			
		||||
              itemIds: contracts.map((c) => c.id),
 | 
			
		||||
              highlightClassName:
 | 
			
		||||
                '!bg-indigo-100 outline outline-2 outline-indigo-300',
 | 
			
		||||
            }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										74
									
								
								web/components/contract/add-comment-bounty.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								web/components/contract/add-comment-bounty.tsx
									
									
									
									
									
										Normal 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>}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								web/components/contract/bountied-contract-badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								web/components/contract/bountied-contract-badge.tsx
									
									
									
									
									
										Normal 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.`
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { Col } from '../layout/col'
 | 
			
		|||
import {
 | 
			
		||||
  BinaryContract,
 | 
			
		||||
  Contract,
 | 
			
		||||
  CPMMBinaryContract,
 | 
			
		||||
  FreeResponseContract,
 | 
			
		||||
  MultipleChoiceContract,
 | 
			
		||||
  NumericContract,
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +33,8 @@ import { track } from '@amplitude/analytics-browser'
 | 
			
		|||
import { trackCallback } from 'web/lib/service/analytics'
 | 
			
		||||
import { getMappedValue } from 'common/pseudo-numeric'
 | 
			
		||||
import { Tooltip } from '../tooltip'
 | 
			
		||||
import { SiteLink } from '../site-link'
 | 
			
		||||
import { ProbChange } from './prob-change-table'
 | 
			
		||||
 | 
			
		||||
export function ContractCard(props: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +46,7 @@ export function ContractCard(props: {
 | 
			
		|||
  hideGroupLink?: boolean
 | 
			
		||||
  trackingPostfix?: string
 | 
			
		||||
  noLinkAvatar?: boolean
 | 
			
		||||
  newTab?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    showTime,
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +57,7 @@ export function ContractCard(props: {
 | 
			
		|||
    hideGroupLink,
 | 
			
		||||
    trackingPostfix,
 | 
			
		||||
    noLinkAvatar,
 | 
			
		||||
    newTab,
 | 
			
		||||
  } = props
 | 
			
		||||
  const contract = useContractWithPreload(props.contract) ?? props.contract
 | 
			
		||||
  const { question, outcomeType } = contract
 | 
			
		||||
| 
						 | 
				
			
			@ -186,6 +191,7 @@ export function ContractCard(props: {
 | 
			
		|||
              }
 | 
			
		||||
            )}
 | 
			
		||||
            className="absolute top-0 left-0 right-0 bottom-0"
 | 
			
		||||
            target={newTab ? '_blank' : '_self'}
 | 
			
		||||
          />
 | 
			
		||||
        </Link>
 | 
			
		||||
      )}
 | 
			
		||||
| 
						 | 
				
			
			@ -208,7 +214,9 @@ export function BinaryResolutionOrChance(props: {
 | 
			
		|||
  const probChanged = before !== after
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
 | 
			
		||||
    <Col
 | 
			
		||||
      className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)}
 | 
			
		||||
    >
 | 
			
		||||
      {resolution ? (
 | 
			
		||||
        <>
 | 
			
		||||
          <div
 | 
			
		||||
| 
						 | 
				
			
			@ -379,3 +387,34 @@ export function PseudoNumericResolutionOrExpectation(props: {
 | 
			
		|||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ContractCardProbChange(props: {
 | 
			
		||||
  contract: CPMMBinaryContract
 | 
			
		||||
  noLinkAvatar?: boolean
 | 
			
		||||
  className?: string
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, noLinkAvatar, className } = props
 | 
			
		||||
  return (
 | 
			
		||||
    <Col
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        className,
 | 
			
		||||
        'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <AvatarDetails
 | 
			
		||||
        contract={contract}
 | 
			
		||||
        className={'px-6 pt-4'}
 | 
			
		||||
        noLink={noLinkAvatar}
 | 
			
		||||
      />
 | 
			
		||||
      <Row className={clsx('items-start justify-between gap-4 ', className)}>
 | 
			
		||||
        <SiteLink
 | 
			
		||||
          className="pl-6 pr-0 pt-2 pb-4 font-semibold text-indigo-700"
 | 
			
		||||
          href={contractPath(contract)}
 | 
			
		||||
        >
 | 
			
		||||
          <span className="line-clamp-3">{contract.question}</span>
 | 
			
		||||
        </SiteLink>
 | 
			
		||||
        <ProbChange className="py-2 pr-4" contract={contract} />
 | 
			
		||||
      </Row>
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,6 +32,10 @@ import { PlusCircleIcon } from '@heroicons/react/solid'
 | 
			
		|||
import { GroupLink } from 'common/group'
 | 
			
		||||
import { Subtitle } from '../subtitle'
 | 
			
		||||
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'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +67,8 @@ export function MiscDetails(props: {
 | 
			
		|||
        </Row>
 | 
			
		||||
      ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
 | 
			
		||||
        <FeaturedContractBadge />
 | 
			
		||||
      ) : (contract.openCommentBounties ?? 0) > 0 ? (
 | 
			
		||||
        <BountiedContractBadge />
 | 
			
		||||
      ) : volume > 0 || !isNew ? (
 | 
			
		||||
        <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
 | 
			
		||||
      ) : (
 | 
			
		||||
| 
						 | 
				
			
			@ -126,9 +132,10 @@ export function ContractDetails(props: {
 | 
			
		|||
      </Row>
 | 
			
		||||
      {/* GROUPS */}
 | 
			
		||||
      {isMobile && (
 | 
			
		||||
        <div className="mt-2">
 | 
			
		||||
        <Row className="mt-2 gap-1">
 | 
			
		||||
          <BountiedContractSmallBadge contract={contract} />
 | 
			
		||||
          <MarketGroups contract={contract} disabled={disabled} />
 | 
			
		||||
        </div>
 | 
			
		||||
        </Row>
 | 
			
		||||
      )}
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			@ -171,14 +178,18 @@ export function MarketSubheader(props: {
 | 
			
		|||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </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
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            resolvedDate={resolvedDate}
 | 
			
		||||
            isCreator={isCreator}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          />
 | 
			
		||||
          {!isMobile && (
 | 
			
		||||
            <MarketGroups contract={contract} disabled={disabled} />
 | 
			
		||||
            <Row className={'gap-1'}>
 | 
			
		||||
              <BountiedContractSmallBadge contract={contract} />
 | 
			
		||||
              <MarketGroups contract={contract} disabled={disabled} />
 | 
			
		||||
            </Row>
 | 
			
		||||
          )}
 | 
			
		||||
        </Row>
 | 
			
		||||
      </Col>
 | 
			
		||||
| 
						 | 
				
			
			@ -190,8 +201,9 @@ export function CloseOrResolveTime(props: {
 | 
			
		|||
  contract: Contract
 | 
			
		||||
  resolvedDate: any
 | 
			
		||||
  isCreator: boolean
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, resolvedDate, isCreator } = props
 | 
			
		||||
  const { contract, resolvedDate, isCreator, disabled } = props
 | 
			
		||||
  const { resolutionTime, closeTime } = contract
 | 
			
		||||
  if (!!closeTime || !!resolvedDate) {
 | 
			
		||||
    return (
 | 
			
		||||
| 
						 | 
				
			
			@ -215,6 +227,7 @@ export function CloseOrResolveTime(props: {
 | 
			
		|||
              closeTime={closeTime}
 | 
			
		||||
              contract={contract}
 | 
			
		||||
              isCreator={isCreator ?? false}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            />
 | 
			
		||||
          </Row>
 | 
			
		||||
        )}
 | 
			
		||||
| 
						 | 
				
			
			@ -235,7 +248,8 @@ export function MarketGroups(props: {
 | 
			
		|||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Row className="items-center gap-1">
 | 
			
		||||
        <GroupDisplay groupToDisplay={groupToDisplay} />
 | 
			
		||||
        <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} />
 | 
			
		||||
 | 
			
		||||
        {!disabled && user && (
 | 
			
		||||
          <button
 | 
			
		||||
            className="text-greyscale-4 hover:text-greyscale-3"
 | 
			
		||||
| 
						 | 
				
			
			@ -320,19 +334,34 @@ export function ExtraMobileContractDetails(props: {
 | 
			
		|||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) {
 | 
			
		||||
  const { groupToDisplay } = props
 | 
			
		||||
export function GroupDisplay(props: {
 | 
			
		||||
  groupToDisplay?: GroupLink | null
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { groupToDisplay, disabled } = props
 | 
			
		||||
 | 
			
		||||
  if (groupToDisplay) {
 | 
			
		||||
    return (
 | 
			
		||||
    const groupSection = (
 | 
			
		||||
      <a
 | 
			
		||||
        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}
 | 
			
		||||
      </a>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return disabled ? (
 | 
			
		||||
      groupSection
 | 
			
		||||
    ) : (
 | 
			
		||||
      <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
 | 
			
		||||
        <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]">
 | 
			
		||||
          {groupToDisplay.name}
 | 
			
		||||
        </a>
 | 
			
		||||
        {groupSection}
 | 
			
		||||
      </Link>
 | 
			
		||||
    )
 | 
			
		||||
  } else
 | 
			
		||||
    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
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			@ -342,8 +371,9 @@ function EditableCloseDate(props: {
 | 
			
		|||
  closeTime: number
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  isCreator: boolean
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { closeTime, contract, isCreator } = props
 | 
			
		||||
  const { closeTime, contract, isCreator, disabled } = props
 | 
			
		||||
 | 
			
		||||
  const dayJsCloseTime = dayjs(closeTime)
 | 
			
		||||
  const dayJsNow = dayjs()
 | 
			
		||||
| 
						 | 
				
			
			@ -356,18 +386,22 @@ function EditableCloseDate(props: {
 | 
			
		|||
    closeTime && dayJsCloseTime.format('HH:mm')
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const newCloseTime = closeDate
 | 
			
		||||
    ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
 | 
			
		||||
    : undefined
 | 
			
		||||
 | 
			
		||||
  const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
 | 
			
		||||
  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 === closeTime) setIsEditingCloseTime(false)
 | 
			
		||||
    else if (newCloseTime > Date.now()) {
 | 
			
		||||
    else {
 | 
			
		||||
      const content = contract.description
 | 
			
		||||
      const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -416,13 +450,21 @@ function EditableCloseDate(props: {
 | 
			
		|||
            />
 | 
			
		||||
          </Row>
 | 
			
		||||
          <Button
 | 
			
		||||
            className="mt-2"
 | 
			
		||||
            className="mt-4"
 | 
			
		||||
            size={'xs'}
 | 
			
		||||
            color={'indigo'}
 | 
			
		||||
            onClick={onSave}
 | 
			
		||||
            onClick={() => onSave()}
 | 
			
		||||
          >
 | 
			
		||||
            Done
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            className="mt-4"
 | 
			
		||||
            size={'xs'}
 | 
			
		||||
            color={'gray-white'}
 | 
			
		||||
            onClick={() => onSave(Date.now())}
 | 
			
		||||
          >
 | 
			
		||||
            Close Now
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Col>
 | 
			
		||||
      </Modal>
 | 
			
		||||
      <DateTimeTooltip
 | 
			
		||||
| 
						 | 
				
			
			@ -430,8 +472,8 @@ function EditableCloseDate(props: {
 | 
			
		|||
        time={closeTime}
 | 
			
		||||
      >
 | 
			
		||||
        <span
 | 
			
		||||
          className={isCreator ? 'cursor-pointer' : ''}
 | 
			
		||||
          onClick={() => isCreator && setIsEditingCloseTime(true)}
 | 
			
		||||
          className={!disabled && isCreator ? 'cursor-pointer' : ''}
 | 
			
		||||
          onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)}
 | 
			
		||||
        >
 | 
			
		||||
          {isSameDay ? (
 | 
			
		||||
            <span className={'capitalize'}> {fromNow(closeTime)}</span>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
 | 
			
		|||
import { Contract } from 'common/contract'
 | 
			
		||||
import { formatMoney } from 'common/util/format'
 | 
			
		||||
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 { Modal } from '../layout/modal'
 | 
			
		||||
import { Title } from '../title'
 | 
			
		||||
| 
						 | 
				
			
			@ -196,9 +196,7 @@ export function ContractInfoDialog(props: {
 | 
			
		|||
          <Row className="flex-wrap">
 | 
			
		||||
            <DuplicateContractButton contract={contract} />
 | 
			
		||||
          </Row>
 | 
			
		||||
          {contract.mechanism === 'cpmm-1' && !contract.resolution && (
 | 
			
		||||
            <LiquidityPanel contract={contract} />
 | 
			
		||||
          )}
 | 
			
		||||
          {!contract.resolution && <LiquidityBountyPanel contract={contract} />}
 | 
			
		||||
        </Col>
 | 
			
		||||
      </Modal>
 | 
			
		||||
    </>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,10 @@
 | 
			
		|||
import { Bet } from 'common/bet'
 | 
			
		||||
import { ContractComment } from 'common/comment'
 | 
			
		||||
import { resolvedPayout } from 'common/calculate'
 | 
			
		||||
import { Contract } from 'common/contract'
 | 
			
		||||
import { formatMoney } from 'common/util/format'
 | 
			
		||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
 | 
			
		||||
import { useState, useMemo, useEffect } from 'react'
 | 
			
		||||
import { listUsers, User } from 'web/lib/firebase/users'
 | 
			
		||||
import { memo } from 'react'
 | 
			
		||||
import { useComments } from 'web/hooks/use-comments'
 | 
			
		||||
import { FeedBet } from '../feed/feed-bets'
 | 
			
		||||
import { FeedComment } from '../feed/feed-comments'
 | 
			
		||||
import { Spacer } from '../layout/spacer'
 | 
			
		||||
| 
						 | 
				
			
			@ -13,61 +12,48 @@ import { Leaderboard } from '../leaderboard'
 | 
			
		|||
import { Title } from '../title'
 | 
			
		||||
import { BETTORS } from 'common/user'
 | 
			
		||||
 | 
			
		||||
export function ContractLeaderboard(props: {
 | 
			
		||||
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  bets: Bet[]
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, bets } = props
 | 
			
		||||
  const [users, setUsers] = useState<User[]>()
 | 
			
		||||
 | 
			
		||||
  const { userProfits, top5Ids } = useMemo(() => {
 | 
			
		||||
    // Create a map of userIds to total profits (including sales)
 | 
			
		||||
    const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
 | 
			
		||||
    const betsByUser = groupBy(openBets, 'userId')
 | 
			
		||||
 | 
			
		||||
    const userProfits = mapValues(betsByUser, (bets) =>
 | 
			
		||||
      sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
 | 
			
		||||
    )
 | 
			
		||||
    // Find the 5 users with the most profits
 | 
			
		||||
    const top5Ids = Object.entries(userProfits)
 | 
			
		||||
      .sort(([_i1, p1], [_i2, p2]) => p2 - p1)
 | 
			
		||||
      .filter(([, p]) => p > 0)
 | 
			
		||||
      .slice(0, 5)
 | 
			
		||||
      .map(([id]) => id)
 | 
			
		||||
    return { userProfits, top5Ids }
 | 
			
		||||
  }, [contract, bets])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (top5Ids.length > 0) {
 | 
			
		||||
      listUsers(top5Ids).then((users) => {
 | 
			
		||||
        const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
 | 
			
		||||
        setUsers(sortedUsers)
 | 
			
		||||
      })
 | 
			
		||||
  // Create a map of userIds to total profits (including sales)
 | 
			
		||||
  const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
 | 
			
		||||
  const betsByUser = groupBy(openBets, 'userId')
 | 
			
		||||
  const userProfits = mapValues(betsByUser, (bets) => {
 | 
			
		||||
    return {
 | 
			
		||||
      name: bets[0].userName,
 | 
			
		||||
      username: bets[0].userUsername,
 | 
			
		||||
      avatarUrl: bets[0].userAvatarUrl,
 | 
			
		||||
      total: sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount),
 | 
			
		||||
    }
 | 
			
		||||
  }, [userProfits, top5Ids])
 | 
			
		||||
  })
 | 
			
		||||
  // Find the 5 users with the most profits
 | 
			
		||||
  const top5 = Object.values(userProfits)
 | 
			
		||||
    .sort((p1, p2) => p2.total - p1.total)
 | 
			
		||||
    .filter((p) => p.total > 0)
 | 
			
		||||
    .slice(0, 5)
 | 
			
		||||
 | 
			
		||||
  return users && users.length > 0 ? (
 | 
			
		||||
  return top5 && top5.length > 0 ? (
 | 
			
		||||
    <Leaderboard
 | 
			
		||||
      title={`🏅 Top ${BETTORS}`}
 | 
			
		||||
      users={users || []}
 | 
			
		||||
      entries={top5 || []}
 | 
			
		||||
      columns={[
 | 
			
		||||
        {
 | 
			
		||||
          header: 'Total profit',
 | 
			
		||||
          renderCell: (user) => formatMoney(userProfits[user.id] || 0),
 | 
			
		||||
          renderCell: (entry) => formatMoney(entry.total),
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      className="mt-12 max-w-sm"
 | 
			
		||||
    />
 | 
			
		||||
  ) : null
 | 
			
		||||
}
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export function ContractTopTrades(props: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  bets: Bet[]
 | 
			
		||||
  comments: ContractComment[]
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, bets, comments } = props
 | 
			
		||||
  const commentsById = keyBy(comments, 'id')
 | 
			
		||||
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
 | 
			
		||||
  const { contract, bets } = props
 | 
			
		||||
  // todo: this stuff should be calced in DB at resolve time
 | 
			
		||||
  const comments = useComments(contract.id)
 | 
			
		||||
  const betsById = keyBy(bets, 'id')
 | 
			
		||||
 | 
			
		||||
  // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
 | 
			
		||||
| 
						 | 
				
			
			@ -88,29 +74,23 @@ export function ContractTopTrades(props: {
 | 
			
		|||
  const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
 | 
			
		||||
  const topBettor = betsById[topBetId]?.userName
 | 
			
		||||
 | 
			
		||||
  // And also the commentId of the comment with the highest profit
 | 
			
		||||
  const topCommentId = sortBy(
 | 
			
		||||
    comments,
 | 
			
		||||
    (c) => c.betId && -profitById[c.betId]
 | 
			
		||||
  )[0]?.id
 | 
			
		||||
  // And also the comment with the highest profit
 | 
			
		||||
  const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="mt-12 max-w-sm">
 | 
			
		||||
      {topCommentId && profitById[topCommentId] > 0 && (
 | 
			
		||||
      {topComment && profitById[topComment.id] > 0 && (
 | 
			
		||||
        <>
 | 
			
		||||
          <Title text="💬 Proven correct" className="!mt-0" />
 | 
			
		||||
          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
			
		||||
            <FeedComment
 | 
			
		||||
              contract={contract}
 | 
			
		||||
              comment={commentsById[topCommentId]}
 | 
			
		||||
            />
 | 
			
		||||
            <FeedComment contract={contract} comment={topComment} />
 | 
			
		||||
          </div>
 | 
			
		||||
          <Spacer h={16} />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* If they're the same, only show the comment; otherwise show both */}
 | 
			
		||||
      {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
 | 
			
		||||
      {topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
 | 
			
		||||
        <>
 | 
			
		||||
          <Title text="💸 Best bet" className="!mt-0" />
 | 
			
		||||
          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,6 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
 | 
			
		||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
 | 
			
		||||
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 { Row } from '../layout/row'
 | 
			
		||||
import { Linkify } from '../linkify'
 | 
			
		||||
| 
						 | 
				
			
			@ -13,20 +11,18 @@ import {
 | 
			
		|||
  PseudoNumericResolutionOrExpectation,
 | 
			
		||||
} from './contract-card'
 | 
			
		||||
import { Bet } from 'common/bet'
 | 
			
		||||
import BetButton from '../bet-button'
 | 
			
		||||
import { AnswersGraph } from '../answers/answers-graph'
 | 
			
		||||
import BetButton, { BinaryMobileBetting } from '../bet-button'
 | 
			
		||||
import {
 | 
			
		||||
  Contract,
 | 
			
		||||
  BinaryContract,
 | 
			
		||||
  CPMMContract,
 | 
			
		||||
  CPMMBinaryContract,
 | 
			
		||||
  FreeResponseContract,
 | 
			
		||||
  MultipleChoiceContract,
 | 
			
		||||
  NumericContract,
 | 
			
		||||
  PseudoNumericContract,
 | 
			
		||||
  BinaryContract,
 | 
			
		||||
} from 'common/contract'
 | 
			
		||||
import { ContractDetails } from './contract-details'
 | 
			
		||||
import { NumericGraph } from './numeric-graph'
 | 
			
		||||
import { SizedContainer } from 'web/components/sized-container'
 | 
			
		||||
 | 
			
		||||
const OverviewQuestion = (props: { text: string }) => (
 | 
			
		||||
  <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 { contract } = props
 | 
			
		||||
const SizedContractChart = (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 (
 | 
			
		||||
    <Col className="gap-1 md:gap-2">
 | 
			
		||||
      <Col className="gap-3 px-2 sm:gap-4">
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +81,12 @@ const NumericOverview = (props: { contract: NumericContract }) => {
 | 
			
		|||
          contract={contract}
 | 
			
		||||
        />
 | 
			
		||||
      </Col>
 | 
			
		||||
      <NumericGraph contract={contract} />
 | 
			
		||||
      <SizedContractChart
 | 
			
		||||
        contract={contract}
 | 
			
		||||
        bets={bets}
 | 
			
		||||
        fullHeight={250}
 | 
			
		||||
        mobileHeight={150}
 | 
			
		||||
      />
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -78,19 +100,23 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
 | 
			
		|||
        <Row className="justify-between gap-4">
 | 
			
		||||
          <OverviewQuestion text={contract.question} />
 | 
			
		||||
          <BinaryResolutionOrChance
 | 
			
		||||
            className="hidden items-end xl:flex"
 | 
			
		||||
            className="flex items-end"
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            large
 | 
			
		||||
          />
 | 
			
		||||
        </Row>
 | 
			
		||||
        <Row className="items-center justify-between gap-4 xl:hidden">
 | 
			
		||||
          <BinaryResolutionOrChance contract={contract} />
 | 
			
		||||
          {tradingAllowed(contract) && (
 | 
			
		||||
            <BetWidget contract={contract as CPMMBinaryContract} />
 | 
			
		||||
          )}
 | 
			
		||||
        </Row>
 | 
			
		||||
      </Col>
 | 
			
		||||
      <ContractProbGraph contract={contract} bets={[...bets].reverse()} />
 | 
			
		||||
      <SizedContractChart
 | 
			
		||||
        contract={contract}
 | 
			
		||||
        bets={bets}
 | 
			
		||||
        fullHeight={250}
 | 
			
		||||
        mobileHeight={150}
 | 
			
		||||
      />
 | 
			
		||||
      <Row className="items-center justify-between gap-4 xl:hidden">
 | 
			
		||||
        {tradingAllowed(contract) && (
 | 
			
		||||
          <BinaryMobileBetting contract={contract} />
 | 
			
		||||
        )}
 | 
			
		||||
      </Row>
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -110,9 +136,12 @@ const ChoiceOverview = (props: {
 | 
			
		|||
          <FreeResponseResolutionOrChance contract={contract} truncate="none" />
 | 
			
		||||
        )}
 | 
			
		||||
      </Col>
 | 
			
		||||
      <Col className={'mb-1 gap-y-2'}>
 | 
			
		||||
        <AnswersGraph contract={contract} bets={[...bets].reverse()} />
 | 
			
		||||
      </Col>
 | 
			
		||||
      <SizedContractChart
 | 
			
		||||
        contract={contract}
 | 
			
		||||
        bets={bets}
 | 
			
		||||
        fullHeight={350}
 | 
			
		||||
        mobileHeight={250}
 | 
			
		||||
      />
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -138,7 +167,12 @@ const PseudoNumericOverview = (props: {
 | 
			
		|||
          {tradingAllowed(contract) && <BetWidget contract={contract} />}
 | 
			
		||||
        </Row>
 | 
			
		||||
      </Col>
 | 
			
		||||
      <ContractProbGraph contract={contract} bets={[...bets].reverse()} />
 | 
			
		||||
      <SizedContractChart
 | 
			
		||||
        contract={contract}
 | 
			
		||||
        bets={bets}
 | 
			
		||||
        fullHeight={250}
 | 
			
		||||
        mobileHeight={150}
 | 
			
		||||
      />
 | 
			
		||||
    </Col>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -152,7 +186,7 @@ export const ContractOverview = (props: {
 | 
			
		|||
    case 'BINARY':
 | 
			
		||||
      return <BinaryOverview contract={contract} bets={bets} />
 | 
			
		||||
    case 'NUMERIC':
 | 
			
		||||
      return <NumericOverview contract={contract} />
 | 
			
		||||
      return <NumericOverview contract={contract} bets={bets} />
 | 
			
		||||
    case 'PSEUDO_NUMERIC':
 | 
			
		||||
      return <PseudoNumericOverview contract={contract} bets={bets} />
 | 
			
		||||
    case 'FREE_RESPONSE':
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 { Contract } from 'common/contract'
 | 
			
		||||
import { ContractComment } from 'common/comment'
 | 
			
		||||
import { PAST_BETS, User } from 'common/user'
 | 
			
		||||
import {
 | 
			
		||||
  ContractCommentsActivity,
 | 
			
		||||
  ContractBetsActivity,
 | 
			
		||||
  FreeResponseContractCommentsActivity,
 | 
			
		||||
} from '../feed/contract-activity'
 | 
			
		||||
import { ContractBetsTable, BetsSummary } from '../bets-list'
 | 
			
		||||
import { PAST_BETS } from 'common/user'
 | 
			
		||||
import { ContractBetsTable } from '../bets-list'
 | 
			
		||||
import { Spacer } from '../layout/spacer'
 | 
			
		||||
import { Tabs } from '../layout/tabs'
 | 
			
		||||
import { Col } from '../layout/col'
 | 
			
		||||
import { LoadingIndicator } from 'web/components/loading-indicator'
 | 
			
		||||
import { useComments } from 'web/hooks/use-comments'
 | 
			
		||||
import { useLiquidity } from 'web/hooks/use-liquidity'
 | 
			
		||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
 | 
			
		||||
| 
						 | 
				
			
			@ -19,27 +22,194 @@ import {
 | 
			
		|||
  DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
 | 
			
		||||
  HOUSE_LIQUIDITY_PROVIDER_ID,
 | 
			
		||||
} 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: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  user: User | null | undefined
 | 
			
		||||
  bets: Bet[]
 | 
			
		||||
  userBets: Bet[]
 | 
			
		||||
  comments: ContractComment[]
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, user, bets } = props
 | 
			
		||||
  const { outcomeType } = contract
 | 
			
		||||
  const isMobile = useIsMobile()
 | 
			
		||||
  const { contract, bets, userBets, comments } = props
 | 
			
		||||
 | 
			
		||||
  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 lps = useLiquidity(contract.id)
 | 
			
		||||
  const comments = useComments(contract.id) ?? props.comments
 | 
			
		||||
  const [sort, setSort] = useState<'Newest' | 'Best'>('Newest')
 | 
			
		||||
  const me = useUser()
 | 
			
		||||
 | 
			
		||||
  const userBets =
 | 
			
		||||
    user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
 | 
			
		||||
  if (comments == null) {
 | 
			
		||||
    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(
 | 
			
		||||
    (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
 | 
			
		||||
  )
 | 
			
		||||
  const visibleLps = (lps ?? []).filter(
 | 
			
		||||
  const visibleLps = lps.filter(
 | 
			
		||||
    (l) =>
 | 
			
		||||
      !l.isAnte &&
 | 
			
		||||
      l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
 | 
			
		||||
| 
						 | 
				
			
			@ -47,77 +217,47 @@ export function ContractTabs(props: {
 | 
			
		|||
      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 && (
 | 
			
		||||
    <ContractBetsActivity
 | 
			
		||||
      contract={contract}
 | 
			
		||||
      bets={visibleBets}
 | 
			
		||||
      lps={visibleLps}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
  )
 | 
			
		||||
  const pageItems = sortBy(items, (item) =>
 | 
			
		||||
    item.type === 'bet'
 | 
			
		||||
      ? -item.bet.createdTime
 | 
			
		||||
      : item.type === 'liquidity'
 | 
			
		||||
      ? -item.lp.createdTime
 | 
			
		||||
      : undefined
 | 
			
		||||
  ).slice(start, end)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tabs
 | 
			
		||||
      currentPageForAnalytics={'contract'}
 | 
			
		||||
      tabs={[
 | 
			
		||||
        { title: 'Comments', content: commentActivity },
 | 
			
		||||
        { title: capitalize(PAST_BETS), content: betActivity },
 | 
			
		||||
        ...(!user || !userBets?.length
 | 
			
		||||
          ? []
 | 
			
		||||
          : [
 | 
			
		||||
              {
 | 
			
		||||
                title: isMobile ? `You` : `Your ${PAST_BETS}`,
 | 
			
		||||
                content: yourTrades,
 | 
			
		||||
              },
 | 
			
		||||
            ]),
 | 
			
		||||
      ]}
 | 
			
		||||
    />
 | 
			
		||||
    <>
 | 
			
		||||
      <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'}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import { Contract } from 'web/lib/firebase/contracts'
 | 
			
		|||
import { User } from 'web/lib/firebase/users'
 | 
			
		||||
import { Col } from '../layout/col'
 | 
			
		||||
import { SiteLink } from '../site-link'
 | 
			
		||||
import { ContractCard } from './contract-card'
 | 
			
		||||
import { ContractCard, ContractCardProbChange } from './contract-card'
 | 
			
		||||
import { ShowTime } from './contract-details'
 | 
			
		||||
import { ContractSearch } from '../contract-search'
 | 
			
		||||
import { useCallback } from 'react'
 | 
			
		||||
| 
						 | 
				
			
			@ -10,9 +10,10 @@ import clsx from 'clsx'
 | 
			
		|||
import { LoadingIndicator } from '../loading-indicator'
 | 
			
		||||
import { VisibilityObserver } from '../visibility-observer'
 | 
			
		||||
import Masonry from 'react-masonry-css'
 | 
			
		||||
import { CPMMBinaryContract } from 'common/contract'
 | 
			
		||||
 | 
			
		||||
export type ContractHighlightOptions = {
 | 
			
		||||
  contractIds?: string[]
 | 
			
		||||
export type CardHighlightOptions = {
 | 
			
		||||
  itemIds?: string[]
 | 
			
		||||
  highlightClassName?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -25,8 +26,9 @@ export function ContractsGrid(props: {
 | 
			
		|||
    hideQuickBet?: boolean
 | 
			
		||||
    hideGroupLink?: boolean
 | 
			
		||||
    noLinkAvatar?: boolean
 | 
			
		||||
    showProbChange?: boolean
 | 
			
		||||
  }
 | 
			
		||||
  highlightOptions?: ContractHighlightOptions
 | 
			
		||||
  highlightOptions?: CardHighlightOptions
 | 
			
		||||
  trackingPostfix?: string
 | 
			
		||||
  breakpointColumns?: { [key: string]: number }
 | 
			
		||||
}) {
 | 
			
		||||
| 
						 | 
				
			
			@ -39,8 +41,9 @@ export function ContractsGrid(props: {
 | 
			
		|||
    highlightOptions,
 | 
			
		||||
    trackingPostfix,
 | 
			
		||||
  } = props
 | 
			
		||||
  const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
 | 
			
		||||
  const { contractIds, highlightClassName } = highlightOptions || {}
 | 
			
		||||
  const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
 | 
			
		||||
    cardUIOptions || {}
 | 
			
		||||
  const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
 | 
			
		||||
  const onVisibilityUpdated = useCallback(
 | 
			
		||||
    (visible) => {
 | 
			
		||||
      if (visible && loadMore) {
 | 
			
		||||
| 
						 | 
				
			
			@ -73,24 +76,31 @@ export function ContractsGrid(props: {
 | 
			
		|||
        className="-ml-4 flex w-auto"
 | 
			
		||||
        columnClassName="pl-4 bg-clip-padding"
 | 
			
		||||
      >
 | 
			
		||||
        {contracts.map((contract) => (
 | 
			
		||||
          <ContractCard
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            key={contract.id}
 | 
			
		||||
            showTime={showTime}
 | 
			
		||||
            onClick={
 | 
			
		||||
              onContractClick ? () => onContractClick(contract) : undefined
 | 
			
		||||
            }
 | 
			
		||||
            noLinkAvatar={noLinkAvatar}
 | 
			
		||||
            hideQuickBet={hideQuickBet}
 | 
			
		||||
            hideGroupLink={hideGroupLink}
 | 
			
		||||
            trackingPostfix={trackingPostfix}
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
 | 
			
		||||
              contractIds?.includes(contract.id) && highlightClassName
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
        {contracts.map((contract) =>
 | 
			
		||||
          showProbChange && contract.mechanism === 'cpmm-1' ? (
 | 
			
		||||
            <ContractCardProbChange
 | 
			
		||||
              key={contract.id}
 | 
			
		||||
              contract={contract as CPMMBinaryContract}
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <ContractCard
 | 
			
		||||
              contract={contract}
 | 
			
		||||
              key={contract.id}
 | 
			
		||||
              showTime={showTime}
 | 
			
		||||
              onClick={
 | 
			
		||||
                onContractClick ? () => onContractClick(contract) : undefined
 | 
			
		||||
              }
 | 
			
		||||
              noLinkAvatar={noLinkAvatar}
 | 
			
		||||
              hideQuickBet={hideQuickBet}
 | 
			
		||||
              hideGroupLink={hideGroupLink}
 | 
			
		||||
              trackingPostfix={trackingPostfix}
 | 
			
		||||
              className={clsx(
 | 
			
		||||
                'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
 | 
			
		||||
                contractIds?.includes(contract.id) && highlightClassName
 | 
			
		||||
              )}
 | 
			
		||||
            />
 | 
			
		||||
          )
 | 
			
		||||
        )}
 | 
			
		||||
      </Masonry>
 | 
			
		||||
      {loadMore && (
 | 
			
		||||
        <VisibilityObserver
 | 
			
		||||
| 
						 | 
				
			
			@ -118,6 +128,7 @@ export function CreatorContractsList(props: {
 | 
			
		|||
        creatorId: creator.id,
 | 
			
		||||
      }}
 | 
			
		||||
      persistPrefix={`user-${creator.id}`}
 | 
			
		||||
      profile={true}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,4 @@
 | 
			
		|||
import clsx from 'clsx'
 | 
			
		||||
import { ShareIcon } from '@heroicons/react/outline'
 | 
			
		||||
 | 
			
		||||
import { Row } from '../layout/row'
 | 
			
		||||
import { Contract } from 'web/lib/firebase/contracts'
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +8,7 @@ import { ShareModal } from './share-modal'
 | 
			
		|||
import { FollowMarketButton } from 'web/components/follow-market-button'
 | 
			
		||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
 | 
			
		||||
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
 | 
			
		||||
import { Col } from 'web/components/layout/col'
 | 
			
		||||
import { Tooltip } from '../tooltip'
 | 
			
		||||
 | 
			
		||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
 | 
			
		||||
  const { contract } = props
 | 
			
		||||
| 
						 | 
				
			
			@ -20,30 +18,24 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
 | 
			
		|||
  return (
 | 
			
		||||
    <Row>
 | 
			
		||||
      <FollowMarketButton contract={contract} user={user} />
 | 
			
		||||
      {user?.id !== contract.creatorId && (
 | 
			
		||||
        <LikeMarketButton contract={contract} user={user} />
 | 
			
		||||
      )}
 | 
			
		||||
      <Button
 | 
			
		||||
        size="sm"
 | 
			
		||||
        color="gray-white"
 | 
			
		||||
        className={'flex'}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setShareOpen(true)
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <Row>
 | 
			
		||||
          <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
 | 
			
		||||
        </Row>
 | 
			
		||||
        <ShareModal
 | 
			
		||||
          isOpen={isShareOpen}
 | 
			
		||||
          setOpen={setShareOpen}
 | 
			
		||||
          contract={contract}
 | 
			
		||||
          user={user}
 | 
			
		||||
        />
 | 
			
		||||
      </Button>
 | 
			
		||||
      <Col className={'justify-center'}>
 | 
			
		||||
        <ContractInfoDialog contract={contract} />
 | 
			
		||||
      </Col>
 | 
			
		||||
      <LikeMarketButton contract={contract} user={user} />
 | 
			
		||||
      <Tooltip text="Share" placement="bottom" noTap noFade>
 | 
			
		||||
        <Button
 | 
			
		||||
          size="sm"
 | 
			
		||||
          color="gray-white"
 | 
			
		||||
          className={'flex'}
 | 
			
		||||
          onClick={() => setShareOpen(true)}
 | 
			
		||||
        >
 | 
			
		||||
          <ShareIcon className="h-5 w-5" aria-hidden />
 | 
			
		||||
          <ShareModal
 | 
			
		||||
            isOpen={isShareOpen}
 | 
			
		||||
            setOpen={setShareOpen}
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            user={user}
 | 
			
		||||
          />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
      <ContractInfoDialog contract={contract} />
 | 
			
		||||
    </Row>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,4 @@
 | 
			
		|||
import { HeartIcon } from '@heroicons/react/outline'
 | 
			
		||||
import { Button } from 'web/components/button'
 | 
			
		||||
import React, { useMemo } from 'react'
 | 
			
		||||
import React, { useMemo, useState } from 'react'
 | 
			
		||||
import { Contract } from 'common/contract'
 | 
			
		||||
import { User } from 'common/user'
 | 
			
		||||
import { useUserLikes } from 'web/hooks/use-likes'
 | 
			
		||||
| 
						 | 
				
			
			@ -8,66 +6,51 @@ import toast from 'react-hot-toast'
 | 
			
		|||
import { formatMoney } from 'common/util/format'
 | 
			
		||||
import { likeContract } from 'web/lib/firebase/likes'
 | 
			
		||||
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 { useMarketTipTxns } from 'web/hooks/use-tip-txns'
 | 
			
		||||
import { sum } from 'lodash'
 | 
			
		||||
import { TipButton } from './tip-button'
 | 
			
		||||
 | 
			
		||||
export function LikeMarketButton(props: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
  user: User | null | undefined
 | 
			
		||||
}) {
 | 
			
		||||
  const { contract, user } = props
 | 
			
		||||
  const tips = useMarketTipTxns(contract.id).filter(
 | 
			
		||||
    (txn) => txn.fromId === user?.id
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const tips = useMarketTipTxns(contract.id)
 | 
			
		||||
 | 
			
		||||
  const totalTipped = useMemo(() => {
 | 
			
		||||
    return sum(tips.map((tip) => tip.amount))
 | 
			
		||||
  }, [tips])
 | 
			
		||||
 | 
			
		||||
  const likes = useUserLikes(user?.id)
 | 
			
		||||
 | 
			
		||||
  const [isLiking, setIsLiking] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const userLikedContractIds = likes
 | 
			
		||||
    ?.filter((l) => l.type === 'contract')
 | 
			
		||||
    .map((l) => l.id)
 | 
			
		||||
 | 
			
		||||
  const onLike = async () => {
 | 
			
		||||
    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)}!`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      size={'sm'}
 | 
			
		||||
      className={'max-w-xs self-center'}
 | 
			
		||||
      color={'gray-white'}
 | 
			
		||||
    <TipButton
 | 
			
		||||
      onClick={onLike}
 | 
			
		||||
    >
 | 
			
		||||
      <Col className={'relative items-center sm:flex-row'}>
 | 
			
		||||
        <HeartIcon
 | 
			
		||||
          className={clsx(
 | 
			
		||||
            'h-5 w-5 sm:h-6 sm:w-6',
 | 
			
		||||
            totalTipped > 0 ? 'mr-2' : '',
 | 
			
		||||
            user &&
 | 
			
		||||
              (userLikedContractIds?.includes(contract.id) ||
 | 
			
		||||
                (!likes && contract.likedByUserIds?.includes(user.id)))
 | 
			
		||||
              ? 'fill-red-500 text-red-500'
 | 
			
		||||
              : ''
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        {totalTipped > 0 && (
 | 
			
		||||
          <div
 | 
			
		||||
            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>
 | 
			
		||||
      tipAmount={LIKE_TIP_AMOUNT}
 | 
			
		||||
      totalTipped={totalTipped}
 | 
			
		||||
      userTipped={
 | 
			
		||||
        !!user &&
 | 
			
		||||
        (isLiking ||
 | 
			
		||||
          userLikedContractIds?.includes(contract.id) ||
 | 
			
		||||
          (!likes && !!contract.likedByUserIds?.includes(user.id)))
 | 
			
		||||
      }
 | 
			
		||||
      disabled={contract.creatorId === user?.id}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,27 +1,30 @@
 | 
			
		|||
import clsx from 'clsx'
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
 | 
			
		||||
import { CPMMContract } from 'common/contract'
 | 
			
		||||
import { Contract, CPMMContract } from 'common/contract'
 | 
			
		||||
import { formatMoney } from 'common/util/format'
 | 
			
		||||
import { useUser } from 'web/hooks/use-user'
 | 
			
		||||
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
 | 
			
		||||
import { AmountInput } from './amount-input'
 | 
			
		||||
import { Row } from './layout/row'
 | 
			
		||||
import { AmountInput } from 'web/components/amount-input'
 | 
			
		||||
import { Row } from 'web/components/layout/row'
 | 
			
		||||
import { useUserLiquidity } from 'web/hooks/use-liquidity'
 | 
			
		||||
import { Tabs } from './layout/tabs'
 | 
			
		||||
import { NoLabel, YesLabel } from './outcome-label'
 | 
			
		||||
import { Col } from './layout/col'
 | 
			
		||||
import { Tabs } from 'web/components/layout/tabs'
 | 
			
		||||
import { NoLabel, YesLabel } from 'web/components/outcome-label'
 | 
			
		||||
import { Col } from 'web/components/layout/col'
 | 
			
		||||
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 { buildArray } from 'common/util/array'
 | 
			
		||||
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 isCPMM = contract.mechanism === 'cpmm-1'
 | 
			
		||||
  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)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
 | 
			
		|||
  const isCreator = user?.id === contract.creatorId
 | 
			
		||||
  const isAdmin = useAdmin()
 | 
			
		||||
 | 
			
		||||
  if (!showWithdrawal && !isAdmin && !isCreator) return <></>
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tabs
 | 
			
		||||
      tabs={buildArray(
 | 
			
		||||
        (isCreator || isAdmin) && {
 | 
			
		||||
          title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
 | 
			
		||||
          content: <AddLiquidityPanel contract={contract} />,
 | 
			
		||||
        },
 | 
			
		||||
        showWithdrawal && {
 | 
			
		||||
          title: 'Withdraw',
 | 
			
		||||
          content: (
 | 
			
		||||
            <WithdrawLiquidityPanel
 | 
			
		||||
              contract={contract}
 | 
			
		||||
              lpShares={lpShares as { YES: number; NO: number }}
 | 
			
		||||
            />
 | 
			
		||||
          ),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          title: 'Pool',
 | 
			
		||||
          content: <ViewLiquidityPanel contract={contract} />,
 | 
			
		||||
        }
 | 
			
		||||
          title: 'Bounty Comments',
 | 
			
		||||
          content: <AddCommentBountyPanel contract={contract} />,
 | 
			
		||||
        },
 | 
			
		||||
        (isCreator || isAdmin) &&
 | 
			
		||||
          isCPMM && {
 | 
			
		||||
            title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
 | 
			
		||||
            content: <AddLiquidityPanel contract={contract} />,
 | 
			
		||||
          },
 | 
			
		||||
        showWithdrawal &&
 | 
			
		||||
          isCPMM && {
 | 
			
		||||
            title: 'Withdraw',
 | 
			
		||||
            content: (
 | 
			
		||||
              <WithdrawLiquidityPanel
 | 
			
		||||
                contract={contract}
 | 
			
		||||
                lpShares={lpShares as { YES: number; NO: number }}
 | 
			
		||||
              />
 | 
			
		||||
            ),
 | 
			
		||||
          },
 | 
			
		||||
 | 
			
		||||
        (isCreator || isAdmin) &&
 | 
			
		||||
          isCPMM && {
 | 
			
		||||
            title: 'Pool',
 | 
			
		||||
            content: <ViewLiquidityPanel contract={contract} />,
 | 
			
		||||
          }
 | 
			
		||||
      )}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import { sortBy } from 'lodash'
 | 
			
		||||
import clsx from 'clsx'
 | 
			
		||||
import { contractPath } from 'web/lib/firebase/contracts'
 | 
			
		||||
import { CPMMContract } from 'common/contract'
 | 
			
		||||
| 
						 | 
				
			
			@ -6,24 +7,24 @@ import { SiteLink } from '../site-link'
 | 
			
		|||
import { Col } from '../layout/col'
 | 
			
		||||
import { Row } from '../layout/row'
 | 
			
		||||
import { LoadingIndicator } from '../loading-indicator'
 | 
			
		||||
import { useContractWithPreload } from 'web/hooks/use-contract'
 | 
			
		||||
 | 
			
		||||
export function ProbChangeTable(props: {
 | 
			
		||||
  changes:
 | 
			
		||||
    | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
 | 
			
		||||
    | undefined
 | 
			
		||||
  changes: CPMMContract[] | undefined
 | 
			
		||||
  full?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const { changes, full } = props
 | 
			
		||||
 | 
			
		||||
  if (!changes) return <LoadingIndicator />
 | 
			
		||||
 | 
			
		||||
  const { positiveChanges, negativeChanges } = changes
 | 
			
		||||
  const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse()
 | 
			
		||||
  const ascendingChanges = sortBy(changes, (c) => c.probChanges.day)
 | 
			
		||||
 | 
			
		||||
  const threshold = 0.01
 | 
			
		||||
  const positiveAboveThreshold = positiveChanges.filter(
 | 
			
		||||
  const positiveAboveThreshold = descendingChanges.filter(
 | 
			
		||||
    (c) => c.probChanges.day > threshold
 | 
			
		||||
  )
 | 
			
		||||
  const negativeAboveThreshold = negativeChanges.filter(
 | 
			
		||||
  const negativeAboveThreshold = ascendingChanges.filter(
 | 
			
		||||
    (c) => c.probChanges.day < threshold
 | 
			
		||||
  )
 | 
			
		||||
  const maxRows = Math.min(
 | 
			
		||||
| 
						 | 
				
			
			@ -53,10 +54,20 @@ export function ProbChangeTable(props: {
 | 
			
		|||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProbChangeRow(props: { contract: CPMMContract }) {
 | 
			
		||||
  const { contract } = props
 | 
			
		||||
export function ProbChangeRow(props: {
 | 
			
		||||
  contract: CPMMContract
 | 
			
		||||
  className?: string
 | 
			
		||||
}) {
 | 
			
		||||
  const { className } = props
 | 
			
		||||
  const contract =
 | 
			
		||||
    (useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
 | 
			
		||||
  return (
 | 
			
		||||
    <Row className="items-center justify-between gap-4 hover:bg-gray-100">
 | 
			
		||||
    <Row
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        'items-center justify-between gap-4 hover:bg-gray-100',
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <SiteLink
 | 
			
		||||
        className="p-4 pr-0 font-semibold text-indigo-700"
 | 
			
		||||
        href={contractPath(contract)}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -344,7 +344,7 @@ export function getColor(contract: Contract) {
 | 
			
		|||
    return (
 | 
			
		||||
      OUTCOME_TO_COLOR[resolution as resolution] ??
 | 
			
		||||
      // 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
 | 
			
		||||
  return 'primary'
 | 
			
		||||
  return 'teal-500'
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ import { CreateChallengeModal } from 'web/components/challenges/create-challenge
 | 
			
		|||
import { useState } from 'react'
 | 
			
		||||
import { CHALLENGES_ENABLED } from 'common/challenge'
 | 
			
		||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
 | 
			
		||||
import { QRCode } from '../qr-code'
 | 
			
		||||
 | 
			
		||||
export function ShareModal(props: {
 | 
			
		||||
  contract: Contract
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +55,12 @@ export function ShareModal(props: {
 | 
			
		|||
          </SiteLink>{' '}
 | 
			
		||||
          if a new user signs up using the link!
 | 
			
		||||
        </p>
 | 
			
		||||
        <QRCode
 | 
			
		||||
          url={shareUrl}
 | 
			
		||||
          className="self-center"
 | 
			
		||||
          width={150}
 | 
			
		||||
          height={150}
 | 
			
		||||
        />
 | 
			
		||||
        <Button
 | 
			
		||||
          size="2xl"
 | 
			
		||||
          color="indigo"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										61
									
								
								web/components/contract/tip-button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/components/contract/tip-button.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								web/components/create-post.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								web/components/create-post.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal'
 | 
			
		|||
import {
 | 
			
		||||
  CheckIcon,
 | 
			
		||||
  CodeIcon,
 | 
			
		||||
  EyeOffIcon,
 | 
			
		||||
  PhotographIcon,
 | 
			
		||||
  PresentationChartLineIcon,
 | 
			
		||||
  TrashIcon,
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon'
 | 
			
		|||
import ItalicIcon from 'web/lib/icons/italic-icon'
 | 
			
		||||
import LinkIcon from 'web/lib/icons/link-icon'
 | 
			
		||||
import { getUrl } from 'common/util/parse'
 | 
			
		||||
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
 | 
			
		||||
 | 
			
		||||
const DisplayImage = Image.configure({
 | 
			
		||||
  HTMLAttributes: {
 | 
			
		||||
| 
						 | 
				
			
			@ -107,6 +109,9 @@ export function useTextEditor(props: {
 | 
			
		|||
      }),
 | 
			
		||||
      Iframe,
 | 
			
		||||
      TiptapTweet,
 | 
			
		||||
      TiptapSpoiler.configure({
 | 
			
		||||
        spoilerOpenClass: 'rounded-sm bg-greyscale-2',
 | 
			
		||||
      }),
 | 
			
		||||
    ],
 | 
			
		||||
    content: defaultValue,
 | 
			
		||||
  })
 | 
			
		||||
| 
						 | 
				
			
			@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) {
 | 
			
		|||
  const isBold = editor.isActive('bold')
 | 
			
		||||
  const isItalic = editor.isActive('italic')
 | 
			
		||||
  const isLink = editor.isActive('link')
 | 
			
		||||
  const isSpoiler = editor.isActive('spoiler')
 | 
			
		||||
 | 
			
		||||
  const setLink = () => {
 | 
			
		||||
    const href = url && getUrl(url)
 | 
			
		||||
| 
						 | 
				
			
			@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) {
 | 
			
		|||
          <button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
 | 
			
		||||
            <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
 | 
			
		||||
          </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,
 | 
			
		||||
      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,
 | 
			
		||||
    editable: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
      ))}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import { Answer } from 'common/answer'
 | 
			
		||||
import { FreeResponseContract } from 'common/contract'
 | 
			
		||||
import { ContractComment } from 'common/comment'
 | 
			
		||||
import React, { useEffect, useState } from 'react'
 | 
			
		||||
import React, { useEffect, useRef, useState } from 'react'
 | 
			
		||||
import { Col } from 'web/components/layout/col'
 | 
			
		||||
import { Row } from 'web/components/layout/row'
 | 
			
		||||
import { Avatar } from 'web/components/avatar'
 | 
			
		||||
| 
						 | 
				
			
			@ -10,11 +10,10 @@ import clsx from 'clsx'
 | 
			
		|||
import {
 | 
			
		||||
  ContractCommentInput,
 | 
			
		||||
  FeedComment,
 | 
			
		||||
  ReplyTo,
 | 
			
		||||
} from 'web/components/feed/feed-comments'
 | 
			
		||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
 | 
			
		||||
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 { UserLink } from 'web/components/user-link'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,32 +26,17 @@ export function FeedAnswerCommentGroup(props: {
 | 
			
		|||
  const { answer, contract, answerComments, tips } = props
 | 
			
		||||
  const { username, avatarUrl, name, text } = answer
 | 
			
		||||
 | 
			
		||||
  const [replyToUser, setReplyToUser] =
 | 
			
		||||
    useState<Pick<User, 'id' | 'username'>>()
 | 
			
		||||
  const [showReply, setShowReply] = useState(false)
 | 
			
		||||
  const [highlighted, setHighlighted] = useState(false)
 | 
			
		||||
  const [replyTo, setReplyTo] = useState<ReplyTo>()
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const answerElementId = `answer-${answer.id}`
 | 
			
		||||
 | 
			
		||||
  const scrollAndOpenReplyInput = useEvent(
 | 
			
		||||
    (comment?: ContractComment, answer?: Answer) => {
 | 
			
		||||
      setReplyToUser(
 | 
			
		||||
        comment
 | 
			
		||||
          ? { id: comment.userId, username: comment.userUsername }
 | 
			
		||||
          : answer
 | 
			
		||||
          ? { id: answer.userId, username: answer.username }
 | 
			
		||||
          : undefined
 | 
			
		||||
      )
 | 
			
		||||
      setShowReply(true)
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
  const highlighted = router.asPath.endsWith(`#${answerElementId}`)
 | 
			
		||||
  const answerRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (router.asPath.endsWith(`#${answerElementId}`)) {
 | 
			
		||||
      setHighlighted(true)
 | 
			
		||||
    if (highlighted && answerRef.current != null) {
 | 
			
		||||
      answerRef.current.scrollIntoView(true)
 | 
			
		||||
    }
 | 
			
		||||
  }, [answerElementId, router.asPath])
 | 
			
		||||
  }, [highlighted])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <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',
 | 
			
		||||
          highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
 | 
			
		||||
        )}
 | 
			
		||||
        ref={answerRef}
 | 
			
		||||
        id={answerElementId}
 | 
			
		||||
      >
 | 
			
		||||
        <Avatar username={username} avatarUrl={avatarUrl} />
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +68,9 @@ export function FeedAnswerCommentGroup(props: {
 | 
			
		|||
            <div className="sm:hidden">
 | 
			
		||||
              <button
 | 
			
		||||
                className="text-xs font-bold text-gray-500 hover:underline"
 | 
			
		||||
                onClick={() => scrollAndOpenReplyInput(undefined, answer)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setReplyTo({ id: answer.id, username: answer.username })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                Reply
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +79,9 @@ export function FeedAnswerCommentGroup(props: {
 | 
			
		|||
          <div className="justify-initial hidden sm:block">
 | 
			
		||||
            <button
 | 
			
		||||
              className="text-xs font-bold text-gray-500 hover:underline"
 | 
			
		||||
              onClick={() => scrollAndOpenReplyInput(undefined, answer)}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                setReplyTo({ id: answer.id, username: answer.username })
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              Reply
 | 
			
		||||
            </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -107,11 +96,13 @@ export function FeedAnswerCommentGroup(props: {
 | 
			
		|||
            contract={contract}
 | 
			
		||||
            comment={comment}
 | 
			
		||||
            tips={tips[comment.id] ?? {}}
 | 
			
		||||
            onReplyClick={scrollAndOpenReplyInput}
 | 
			
		||||
            onReplyClick={() =>
 | 
			
		||||
              setReplyTo({ id: comment.id, username: comment.userUsername })
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Col>
 | 
			
		||||
      {showReply && (
 | 
			
		||||
      {replyTo && (
 | 
			
		||||
        <div className="relative ml-7">
 | 
			
		||||
          <span
 | 
			
		||||
            className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
 | 
			
		||||
| 
						 | 
				
			
			@ -120,8 +111,8 @@ export function FeedAnswerCommentGroup(props: {
 | 
			
		|||
          <ContractCommentInput
 | 
			
		||||
            contract={contract}
 | 
			
		||||
            parentAnswerOutcome={answer.number.toString()}
 | 
			
		||||
            replyToUser={replyToUser}
 | 
			
		||||
            onSubmitComment={() => setShowReply(false)}
 | 
			
		||||
            replyTo={replyTo}
 | 
			
		||||
            onSubmitComment={() => setReplyTo(undefined)}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue
	
	Block a user