Merge branch 'main' into autosave
This commit is contained in:
		
						commit
						5efdd157d9
					
				
							
								
								
									
										17
									
								
								.github/workflows/merge-main-into-main2.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/merge-main-into-main2.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					name: Merge main into main2 on every commit
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - 'main'
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  merge-branch:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - uses: actions/checkout@master
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Merge main -> main2
 | 
				
			||||||
 | 
					        uses: devmasx/merge-branch@master
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          type: now
 | 
				
			||||||
 | 
					          target_branch: main2
 | 
				
			||||||
 | 
					          github_token: ${{ github.token }}
 | 
				
			||||||
							
								
								
									
										123
									
								
								common/badge.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								common/badge.ts
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,123 @@
 | 
				
			||||||
 | 
					import { User } from './user'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type Badge = {
 | 
				
			||||||
 | 
					  type: BadgeTypes
 | 
				
			||||||
 | 
					  createdTime: number
 | 
				
			||||||
 | 
					  data: { [key: string]: any }
 | 
				
			||||||
 | 
					  name: 'Proven Correct' | 'Streaker' | 'Market Creator'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ProvenCorrectBadgeData = {
 | 
				
			||||||
 | 
					  type: 'PROVEN_CORRECT'
 | 
				
			||||||
 | 
					  data: {
 | 
				
			||||||
 | 
					    contractSlug: string
 | 
				
			||||||
 | 
					    contractCreatorUsername: string
 | 
				
			||||||
 | 
					    contractTitle: string
 | 
				
			||||||
 | 
					    commentId: string
 | 
				
			||||||
 | 
					    betAmount: number
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type MarketCreatorBadgeData = {
 | 
				
			||||||
 | 
					  type: 'MARKET_CREATOR'
 | 
				
			||||||
 | 
					  data: {
 | 
				
			||||||
 | 
					    totalContractsCreated: number
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type StreakerBadgeData = {
 | 
				
			||||||
 | 
					  type: 'STREAKER'
 | 
				
			||||||
 | 
					  data: {
 | 
				
			||||||
 | 
					    totalBettingStreak: number
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
 | 
				
			||||||
 | 
					export type StreakerBadge = Badge & StreakerBadgeData
 | 
				
			||||||
 | 
					export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
 | 
				
			||||||
 | 
					export const provenCorrectRarityThresholds = [1, 1000, 10000]
 | 
				
			||||||
 | 
					const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
 | 
				
			||||||
 | 
					  const { betAmount } = badge.data
 | 
				
			||||||
 | 
					  const thresholdArray = provenCorrectRarityThresholds
 | 
				
			||||||
 | 
					  let i = thresholdArray.length - 1
 | 
				
			||||||
 | 
					  while (i >= 0) {
 | 
				
			||||||
 | 
					    if (betAmount >= thresholdArray[i]) {
 | 
				
			||||||
 | 
					      return i + 1
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    i--
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return 1
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const streakerBadgeRarityThresholds = [1, 50, 250]
 | 
				
			||||||
 | 
					const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
 | 
				
			||||||
 | 
					  const { totalBettingStreak } = badge.data
 | 
				
			||||||
 | 
					  const thresholdArray = streakerBadgeRarityThresholds
 | 
				
			||||||
 | 
					  let i = thresholdArray.length - 1
 | 
				
			||||||
 | 
					  while (i >= 0) {
 | 
				
			||||||
 | 
					    if (totalBettingStreak == thresholdArray[i]) {
 | 
				
			||||||
 | 
					      return i + 1
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    i--
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return 1
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
 | 
				
			||||||
 | 
					const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
 | 
				
			||||||
 | 
					  const { totalContractsCreated } = badge.data
 | 
				
			||||||
 | 
					  const thresholdArray = marketCreatorBadgeRarityThresholds
 | 
				
			||||||
 | 
					  let i = thresholdArray.length - 1
 | 
				
			||||||
 | 
					  while (i >= 0) {
 | 
				
			||||||
 | 
					    if (totalContractsCreated == thresholdArray[i]) {
 | 
				
			||||||
 | 
					      return i + 1
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    i--
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return 1
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type rarities = 'bronze' | 'silver' | 'gold'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const rarityRanks: { [key: number]: rarities } = {
 | 
				
			||||||
 | 
					  1: 'bronze',
 | 
				
			||||||
 | 
					  2: 'silver',
 | 
				
			||||||
 | 
					  3: 'gold',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const calculateBadgeRarity = (badge: Badge) => {
 | 
				
			||||||
 | 
					  switch (badge.type) {
 | 
				
			||||||
 | 
					    case 'PROVEN_CORRECT':
 | 
				
			||||||
 | 
					      return rarityRanks[
 | 
				
			||||||
 | 
					        calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    case 'MARKET_CREATOR':
 | 
				
			||||||
 | 
					      return rarityRanks[
 | 
				
			||||||
 | 
					        calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    case 'STREAKER':
 | 
				
			||||||
 | 
					      return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
 | 
				
			||||||
 | 
					    default:
 | 
				
			||||||
 | 
					      return rarityRanks[0]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getBadgesByRarity = (user: User | null | undefined) => {
 | 
				
			||||||
 | 
					  const rarities: { [key in rarities]: number } = {
 | 
				
			||||||
 | 
					    bronze: 0,
 | 
				
			||||||
 | 
					    silver: 0,
 | 
				
			||||||
 | 
					    gold: 0,
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (!user) return rarities
 | 
				
			||||||
 | 
					  Object.values(user.achievements).map((value) => {
 | 
				
			||||||
 | 
					    value.badges.map((badge) => {
 | 
				
			||||||
 | 
					      rarities[calculateBadgeRarity(badge)] =
 | 
				
			||||||
 | 
					        (rarities[calculateBadgeRarity(badge)] ?? 0) + 1
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  return rarities
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,17 @@
 | 
				
			||||||
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
 | 
					import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
 | 
				
			||||||
import { calculatePayout } from './calculate'
 | 
					import { calculatePayout, getContractBetMetrics } from './calculate'
 | 
				
			||||||
import { Bet, LimitBet } from './bet'
 | 
					import { Bet, LimitBet } from './bet'
 | 
				
			||||||
import { Contract, CPMMContract, DPMContract } from './contract'
 | 
					import {
 | 
				
			||||||
 | 
					  Contract,
 | 
				
			||||||
 | 
					  CPMMBinaryContract,
 | 
				
			||||||
 | 
					  CPMMContract,
 | 
				
			||||||
 | 
					  DPMContract,
 | 
				
			||||||
 | 
					} from './contract'
 | 
				
			||||||
import { PortfolioMetrics, User } from './user'
 | 
					import { PortfolioMetrics, User } from './user'
 | 
				
			||||||
import { DAY_MS } from './util/time'
 | 
					import { DAY_MS } from './util/time'
 | 
				
			||||||
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
 | 
					import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
 | 
				
			||||||
import { getCpmmProbability } from './calculate-cpmm'
 | 
					import { getCpmmProbability } from './calculate-cpmm'
 | 
				
			||||||
 | 
					import { removeUndefinedProps } from './util/object'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const computeInvestmentValue = (
 | 
					const computeInvestmentValue = (
 | 
				
			||||||
  bets: Bet[],
 | 
					  bets: Bet[],
 | 
				
			||||||
| 
						 | 
					@ -35,8 +41,7 @@ export const computeInvestmentValueCustomProb = (
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const betP = outcome === 'YES' ? p : 1 - p
 | 
					    const betP = outcome === 'YES' ? p : 1 - p
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const payout = betP * shares
 | 
					    const value = betP * shares
 | 
				
			||||||
    const value = payout - (bet.loanAmount ?? 0)
 | 
					 | 
				
			||||||
    if (isNaN(value)) return 0
 | 
					    if (isNaN(value)) return 0
 | 
				
			||||||
    return value
 | 
					    return value
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
| 
						 | 
					@ -97,7 +102,11 @@ export const computeBinaryCpmmElasticity = (
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
  const resultNo = getCpmmProbability(poolN, pN)
 | 
					  const resultNo = getCpmmProbability(poolN, pN)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return resultYes - resultNo
 | 
					  // handle AMM overflow
 | 
				
			||||||
 | 
					  const safeYes = Number.isFinite(resultYes) ? resultYes : 1
 | 
				
			||||||
 | 
					  const safeNo = Number.isFinite(resultNo) ? resultNo : 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return safeYes - safeNo
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const computeDpmElasticity = (
 | 
					export const computeDpmElasticity = (
 | 
				
			||||||
| 
						 | 
					@ -190,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const calculateProfitForPeriod = (
 | 
					const calculateProfitForPeriod = (
 | 
				
			||||||
  startTime: number,
 | 
					  startingPortfolio: PortfolioMetrics | undefined,
 | 
				
			||||||
  descendingPortfolio: PortfolioMetrics[],
 | 
					 | 
				
			||||||
  currentProfit: number
 | 
					  currentProfit: number
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  const startingPortfolio = descendingPortfolio.find(
 | 
					 | 
				
			||||||
    (p) => p.timestamp < startTime
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (startingPortfolio === undefined) {
 | 
					  if (startingPortfolio === undefined) {
 | 
				
			||||||
    return currentProfit
 | 
					    return currentProfit
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -212,33 +216,88 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const calculateNewProfit = (
 | 
					export const calculateNewProfit = (
 | 
				
			||||||
  portfolioHistory: PortfolioMetrics[],
 | 
					  portfolioHistory: Record<
 | 
				
			||||||
 | 
					    'current' | 'day' | 'week' | 'month',
 | 
				
			||||||
 | 
					    PortfolioMetrics | undefined
 | 
				
			||||||
 | 
					  >,
 | 
				
			||||||
  newPortfolio: PortfolioMetrics
 | 
					  newPortfolio: PortfolioMetrics
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  const allTimeProfit = calculatePortfolioProfit(newPortfolio)
 | 
					  const allTimeProfit = calculatePortfolioProfit(newPortfolio)
 | 
				
			||||||
  const descendingPortfolio = sortBy(
 | 
					 | 
				
			||||||
    portfolioHistory,
 | 
					 | 
				
			||||||
    (p) => p.timestamp
 | 
					 | 
				
			||||||
  ).reverse()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const newProfit = {
 | 
					  const newProfit = {
 | 
				
			||||||
    daily: calculateProfitForPeriod(
 | 
					    daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
 | 
				
			||||||
      Date.now() - 1 * DAY_MS,
 | 
					    weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
 | 
				
			||||||
      descendingPortfolio,
 | 
					    monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
 | 
				
			||||||
      allTimeProfit
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    weekly: calculateProfitForPeriod(
 | 
					 | 
				
			||||||
      Date.now() - 7 * DAY_MS,
 | 
					 | 
				
			||||||
      descendingPortfolio,
 | 
					 | 
				
			||||||
      allTimeProfit
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    monthly: calculateProfitForPeriod(
 | 
					 | 
				
			||||||
      Date.now() - 30 * DAY_MS,
 | 
					 | 
				
			||||||
      descendingPortfolio,
 | 
					 | 
				
			||||||
      allTimeProfit
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    allTime: allTimeProfit,
 | 
					    allTime: allTimeProfit,
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return newProfit
 | 
					  return newProfit
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const calculateMetricsByContract = (
 | 
				
			||||||
 | 
					  bets: Bet[],
 | 
				
			||||||
 | 
					  contractsById: Dictionary<Contract>
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const betsByContract = groupBy(bets, (bet) => bet.contractId)
 | 
				
			||||||
 | 
					  const unresolvedContracts = Object.keys(betsByContract)
 | 
				
			||||||
 | 
					    .map((cid) => contractsById[cid])
 | 
				
			||||||
 | 
					    .filter((c) => c && !c.isResolved)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return unresolvedContracts.map((c) => {
 | 
				
			||||||
 | 
					    const bets = betsByContract[c.id] ?? []
 | 
				
			||||||
 | 
					    const current = getContractBetMetrics(c, bets)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let periodMetrics
 | 
				
			||||||
 | 
					    if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
 | 
				
			||||||
 | 
					      const periods = ['day', 'week', 'month'] as const
 | 
				
			||||||
 | 
					      periodMetrics = Object.fromEntries(
 | 
				
			||||||
 | 
					        periods.map((period) => [
 | 
				
			||||||
 | 
					          period,
 | 
				
			||||||
 | 
					          calculatePeriodProfit(c, bets, period),
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return removeUndefinedProps({
 | 
				
			||||||
 | 
					      contractId: c.id,
 | 
				
			||||||
 | 
					      ...current,
 | 
				
			||||||
 | 
					      from: periodMetrics,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const calculatePeriodProfit = (
 | 
				
			||||||
 | 
					  contract: CPMMBinaryContract,
 | 
				
			||||||
 | 
					  bets: Bet[],
 | 
				
			||||||
 | 
					  period: 'day' | 'week' | 'month'
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
 | 
				
			||||||
 | 
					  const fromTime = Date.now() - days * DAY_MS
 | 
				
			||||||
 | 
					  const previousBets = bets.filter((b) => b.createdTime < fromTime)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const prevProb = contract.prob - contract.probChanges[period]
 | 
				
			||||||
 | 
					  const prob = contract.resolutionProbability
 | 
				
			||||||
 | 
					    ? contract.resolutionProbability
 | 
				
			||||||
 | 
					    : contract.prob
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const previousBetsValue = computeInvestmentValueCustomProb(
 | 
				
			||||||
 | 
					    previousBets,
 | 
				
			||||||
 | 
					    contract,
 | 
				
			||||||
 | 
					    prevProb
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  const currentBetsValue = computeInvestmentValueCustomProb(
 | 
				
			||||||
 | 
					    previousBets,
 | 
				
			||||||
 | 
					    contract,
 | 
				
			||||||
 | 
					    prob
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  const profit = currentBetsValue - previousBetsValue
 | 
				
			||||||
 | 
					  const profitPercent =
 | 
				
			||||||
 | 
					    previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    profit,
 | 
				
			||||||
 | 
					    profitPercent,
 | 
				
			||||||
 | 
					    prevValue: previousBetsValue,
 | 
				
			||||||
 | 
					    value: currentBetsValue,
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -215,7 +215,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const profit = payout + saleValue + redeemed - totalInvested
 | 
					  const profit = payout + saleValue + redeemed - totalInvested
 | 
				
			||||||
  const profitPercent = (profit / totalInvested) * 100
 | 
					  const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
 | 
					  const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
 | 
				
			||||||
  const hasShares = Object.values(totalShares).some(
 | 
					  const hasShares = Object.values(totalShares).some(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -63,6 +63,7 @@ export function getNewContract(
 | 
				
			||||||
    tags: [],
 | 
					    tags: [],
 | 
				
			||||||
    lowercaseTags: [],
 | 
					    lowercaseTags: [],
 | 
				
			||||||
    visibility,
 | 
					    visibility,
 | 
				
			||||||
 | 
					    unlistedById: visibility === 'unlisted' ? creator.id : undefined,
 | 
				
			||||||
    isResolved: false,
 | 
					    isResolved: false,
 | 
				
			||||||
    createdTime: Date.now(),
 | 
					    createdTime: Date.now(),
 | 
				
			||||||
    closeTime,
 | 
					    closeTime,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,7 +4,7 @@ export type Notification = {
 | 
				
			||||||
  id: string
 | 
					  id: string
 | 
				
			||||||
  userId: string
 | 
					  userId: string
 | 
				
			||||||
  reasonText?: string
 | 
					  reasonText?: string
 | 
				
			||||||
  reason?: notification_reason_types
 | 
					  reason?: notification_reason_types | notification_preference
 | 
				
			||||||
  createdTime: number
 | 
					  createdTime: number
 | 
				
			||||||
  viewTime?: number
 | 
					  viewTime?: number
 | 
				
			||||||
  isSeen: boolean
 | 
					  isSeen: boolean
 | 
				
			||||||
| 
						 | 
					@ -46,6 +46,7 @@ export type notification_source_types =
 | 
				
			||||||
  | 'loan'
 | 
					  | 'loan'
 | 
				
			||||||
  | 'like'
 | 
					  | 'like'
 | 
				
			||||||
  | 'tip_and_like'
 | 
					  | 'tip_and_like'
 | 
				
			||||||
 | 
					  | 'badge'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type notification_source_update_types =
 | 
					export type notification_source_update_types =
 | 
				
			||||||
  | 'created'
 | 
					  | 'created'
 | 
				
			||||||
| 
						 | 
					@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
 | 
				
			||||||
    simple: `Only on markets you're invested in`,
 | 
					    simple: `Only on markets you're invested in`,
 | 
				
			||||||
    detailed: `Answers on markets that you're watching and that you're invested in`,
 | 
					    detailed: `Answers on markets that you're watching and that you're invested in`,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  badges_awarded: {
 | 
				
			||||||
 | 
					    simple: 'New badges awarded',
 | 
				
			||||||
 | 
					    detailed: 'New badges you have earned',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  opt_out_all: {
 | 
					  opt_out_all: {
 | 
				
			||||||
    simple: 'Opt out of all notifications (excludes when your markets close)',
 | 
					    simple: 'Opt out of all notifications (excludes when your markets close)',
 | 
				
			||||||
    detailed:
 | 
					    detailed:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,9 @@
 | 
				
			||||||
import { groupBy, sumBy, mapValues } from 'lodash'
 | 
					import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Bet } from './bet'
 | 
					import { Bet } from './bet'
 | 
				
			||||||
import { getContractBetMetrics } from './calculate'
 | 
					import { getContractBetMetrics, resolvedPayout } from './calculate'
 | 
				
			||||||
import { Contract } from './contract'
 | 
					import { Contract } from './contract'
 | 
				
			||||||
 | 
					import { ContractComment } from './comment'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function scoreCreators(contracts: Contract[]) {
 | 
					export function scoreCreators(contracts: Contract[]) {
 | 
				
			||||||
  const creatorScore = mapValues(
 | 
					  const creatorScore = mapValues(
 | 
				
			||||||
| 
						 | 
					@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
 | 
					export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
 | 
				
			||||||
  const betsByUser = groupBy(bets, bet => bet.userId)
 | 
					  const betsByUser = groupBy(bets, (bet) => bet.userId)
 | 
				
			||||||
  return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
 | 
					  return mapValues(
 | 
				
			||||||
 | 
					    betsByUser,
 | 
				
			||||||
 | 
					    (bets) => getContractBetMetrics(contract, bets).profit
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function addUserScores(
 | 
					export function addUserScores(
 | 
				
			||||||
| 
						 | 
					@ -43,3 +47,47 @@ export function addUserScores(
 | 
				
			||||||
    dest[userId] += score
 | 
					    dest[userId] += score
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function scoreCommentorsAndBettors(
 | 
				
			||||||
 | 
					  contract: Contract,
 | 
				
			||||||
 | 
					  bets: Bet[],
 | 
				
			||||||
 | 
					  comments: ContractComment[]
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  const commentsById = keyBy(comments, 'id')
 | 
				
			||||||
 | 
					  const betsById = keyBy(bets, 'id')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
 | 
				
			||||||
 | 
					  // Otherwise, we record the profit at resolution time
 | 
				
			||||||
 | 
					  const profitById: Record<string, number> = {}
 | 
				
			||||||
 | 
					  for (const bet of bets) {
 | 
				
			||||||
 | 
					    if (bet.sale) {
 | 
				
			||||||
 | 
					      const originalBet = betsById[bet.sale.betId]
 | 
				
			||||||
 | 
					      const profit = bet.sale.amount - originalBet.amount
 | 
				
			||||||
 | 
					      profitById[bet.id] = profit
 | 
				
			||||||
 | 
					      profitById[originalBet.id] = profit
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Now find the betId with the highest profit
 | 
				
			||||||
 | 
					  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
 | 
				
			||||||
 | 
					  const topCommentBetId = commentsById[topCommentId]?.betId
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    topCommentId,
 | 
				
			||||||
 | 
					    topBetId,
 | 
				
			||||||
 | 
					    topBettor,
 | 
				
			||||||
 | 
					    profitById,
 | 
				
			||||||
 | 
					    commentsById,
 | 
				
			||||||
 | 
					    betsById,
 | 
				
			||||||
 | 
					    topCommentBetId,
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -53,7 +53,7 @@ export type notification_preferences = {
 | 
				
			||||||
  profit_loss_updates: notification_destination_types[]
 | 
					  profit_loss_updates: notification_destination_types[]
 | 
				
			||||||
  onboarding_flow: notification_destination_types[]
 | 
					  onboarding_flow: notification_destination_types[]
 | 
				
			||||||
  thank_you_for_purchases: notification_destination_types[]
 | 
					  thank_you_for_purchases: notification_destination_types[]
 | 
				
			||||||
 | 
					  badges_awarded: notification_destination_types[]
 | 
				
			||||||
  opt_out_all: notification_destination_types[]
 | 
					  opt_out_all: notification_destination_types[]
 | 
				
			||||||
  // When adding a new notification preference, use add-new-notification-preference.ts to existing users
 | 
					  // When adding a new notification preference, use add-new-notification-preference.ts to existing users
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
 | 
				
			||||||
    onboarding_flow: constructPref(false, false),
 | 
					    onboarding_flow: constructPref(false, false),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    opt_out_all: [],
 | 
					    opt_out_all: [],
 | 
				
			||||||
 | 
					    badges_awarded: constructPref(true, false),
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return defaults
 | 
					  return defaults
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -178,31 +179,44 @@ export const getNotificationDestinationsForUser = (
 | 
				
			||||||
  reason: notification_reason_types | notification_preference
 | 
					  reason: notification_reason_types | notification_preference
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  const notificationSettings = privateUser.notificationPreferences
 | 
					  const notificationSettings = privateUser.notificationPreferences
 | 
				
			||||||
  let destinations
 | 
					 | 
				
			||||||
  let subscriptionType: notification_preference | undefined
 | 
					 | 
				
			||||||
  if (Object.keys(notificationSettings).includes(reason)) {
 | 
					 | 
				
			||||||
    subscriptionType = reason as notification_preference
 | 
					 | 
				
			||||||
    destinations = notificationSettings[subscriptionType]
 | 
					 | 
				
			||||||
  } else {
 | 
					 | 
				
			||||||
    const key = reason as notification_reason_types
 | 
					 | 
				
			||||||
    subscriptionType = notificationReasonToSubscriptionType[key]
 | 
					 | 
				
			||||||
    destinations = subscriptionType
 | 
					 | 
				
			||||||
      ? notificationSettings[subscriptionType]
 | 
					 | 
				
			||||||
      : []
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const optOutOfAllSettings = notificationSettings['opt_out_all']
 | 
					 | 
				
			||||||
  // Your market closure notifications are high priority, opt-out doesn't affect their delivery
 | 
					 | 
				
			||||||
  const optedOutOfEmail =
 | 
					 | 
				
			||||||
    optOutOfAllSettings.includes('email') &&
 | 
					 | 
				
			||||||
    subscriptionType !== 'your_contract_closed'
 | 
					 | 
				
			||||||
  const optedOutOfBrowser =
 | 
					 | 
				
			||||||
    optOutOfAllSettings.includes('browser') &&
 | 
					 | 
				
			||||||
    subscriptionType !== 'your_contract_closed'
 | 
					 | 
				
			||||||
  const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
 | 
					  const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
 | 
				
			||||||
  return {
 | 
					  try {
 | 
				
			||||||
    sendToEmail: destinations.includes('email') && !optedOutOfEmail,
 | 
					    let destinations
 | 
				
			||||||
    sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
 | 
					    let subscriptionType: notification_preference | undefined
 | 
				
			||||||
    unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
 | 
					    if (Object.keys(notificationSettings).includes(reason)) {
 | 
				
			||||||
    urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
 | 
					      subscriptionType = reason as notification_preference
 | 
				
			||||||
 | 
					      destinations = notificationSettings[subscriptionType]
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const key = reason as notification_reason_types
 | 
				
			||||||
 | 
					      subscriptionType = notificationReasonToSubscriptionType[key]
 | 
				
			||||||
 | 
					      destinations = subscriptionType
 | 
				
			||||||
 | 
					        ? notificationSettings[subscriptionType]
 | 
				
			||||||
 | 
					        : []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const optOutOfAllSettings = notificationSettings['opt_out_all']
 | 
				
			||||||
 | 
					    // Your market closure notifications are high priority, opt-out doesn't affect their delivery
 | 
				
			||||||
 | 
					    const optedOutOfEmail =
 | 
				
			||||||
 | 
					      optOutOfAllSettings.includes('email') &&
 | 
				
			||||||
 | 
					      subscriptionType !== 'your_contract_closed'
 | 
				
			||||||
 | 
					    const optedOutOfBrowser =
 | 
				
			||||||
 | 
					      optOutOfAllSettings.includes('browser') &&
 | 
				
			||||||
 | 
					      subscriptionType !== 'your_contract_closed'
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      sendToEmail: destinations.includes('email') && !optedOutOfEmail,
 | 
				
			||||||
 | 
					      sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
 | 
				
			||||||
 | 
					      unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
 | 
				
			||||||
 | 
					      urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (e) {
 | 
				
			||||||
 | 
					    // Fail safely
 | 
				
			||||||
 | 
					    console.log(
 | 
				
			||||||
 | 
					      `couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      sendToEmail: false,
 | 
				
			||||||
 | 
					      sendToBrowser: false,
 | 
				
			||||||
 | 
					      unsubscribeUrl: '',
 | 
				
			||||||
 | 
					      urlToManageThisNotification: '',
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
import { notification_preferences } from './user-notification-preferences'
 | 
					import { notification_preferences } from './user-notification-preferences'
 | 
				
			||||||
import { ENV_CONFIG } from 'common/envs/constants'
 | 
					import { ENV_CONFIG } from './envs/constants'
 | 
				
			||||||
 | 
					import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type User = {
 | 
					export type User = {
 | 
				
			||||||
  id: string
 | 
					  id: string
 | 
				
			||||||
| 
						 | 
					@ -11,7 +12,6 @@ export type User = {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // For their user page
 | 
					  // For their user page
 | 
				
			||||||
  bio?: string
 | 
					  bio?: string
 | 
				
			||||||
  bannerUrl?: string
 | 
					 | 
				
			||||||
  website?: string
 | 
					  website?: string
 | 
				
			||||||
  twitterHandle?: string
 | 
					  twitterHandle?: string
 | 
				
			||||||
  discordHandle?: string
 | 
					  discordHandle?: string
 | 
				
			||||||
| 
						 | 
					@ -51,6 +51,18 @@ export type User = {
 | 
				
			||||||
  hasSeenContractFollowModal?: boolean
 | 
					  hasSeenContractFollowModal?: boolean
 | 
				
			||||||
  freeMarketsCreated?: number
 | 
					  freeMarketsCreated?: number
 | 
				
			||||||
  isBannedFromPosting?: boolean
 | 
					  isBannedFromPosting?: boolean
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  achievements: {
 | 
				
			||||||
 | 
					    provenCorrect?: {
 | 
				
			||||||
 | 
					      badges: ProvenCorrectBadge[]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    marketCreator?: {
 | 
				
			||||||
 | 
					      badges: MarketCreatorBadge[]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    streaker?: {
 | 
				
			||||||
 | 
					      badges: StreakerBadge[]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type PrivateUser = {
 | 
					export type PrivateUser = {
 | 
				
			||||||
| 
						 | 
					@ -81,7 +93,8 @@ export type PortfolioMetrics = {
 | 
				
			||||||
  userId: string
 | 
					  userId: string
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
 | 
					export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
 | 
				
			||||||
 | 
					export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
 | 
				
			||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
 | 
					export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// TODO: remove. Hardcoding the strings would be better.
 | 
					// TODO: remove. Hardcoding the strings would be better.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -52,7 +52,7 @@ export function parseMentions(data: JSONContent): string[] {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
 | 
					// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
 | 
				
			||||||
export const exhibitExts = [
 | 
					const stringParseExts = [
 | 
				
			||||||
  Blockquote,
 | 
					  Blockquote,
 | 
				
			||||||
  Bold,
 | 
					  Bold,
 | 
				
			||||||
  BulletList,
 | 
					  BulletList,
 | 
				
			||||||
| 
						 | 
					@ -72,7 +72,8 @@ export const exhibitExts = [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Image,
 | 
					  Image,
 | 
				
			||||||
  Link,
 | 
					  Link,
 | 
				
			||||||
  Mention,
 | 
					  Mention, // user @mention
 | 
				
			||||||
 | 
					  Mention.extend({ name: 'contract-mention' }), // market %mention
 | 
				
			||||||
  Iframe,
 | 
					  Iframe,
 | 
				
			||||||
  TiptapTweet,
 | 
					  TiptapTweet,
 | 
				
			||||||
  TiptapSpoiler,
 | 
					  TiptapSpoiler,
 | 
				
			||||||
| 
						 | 
					@ -96,7 +97,7 @@ export function richTextToString(text?: JSONContent) {
 | 
				
			||||||
      current.type = 'text'
 | 
					      current.type = 'text'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
  return generateText(newText, exhibitExts)
 | 
					  return generateText(newText, stringParseExts)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
 | 
					const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Awarded bounties
 | 
					## Awarded bounties
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					💥 *Awarded on 2022-10-07*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**[Pepe](https://manifold.markets/Pepe): M$10,000**
 | 
				
			||||||
 | 
					**[Jack](https://manifold.markets/jack): M$2,000**
 | 
				
			||||||
 | 
					**[Martin](https://manifold.markets/MartinRandall): M$2,000**
 | 
				
			||||||
 | 
					**[Yev](https://manifold.markets/Yev): M$2,000**
 | 
				
			||||||
 | 
					**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**[Matt](https://manifold.markets/MattP): M$5,000**
 | 
				
			||||||
 | 
					**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
 | 
				
			||||||
 | 
					**[Yev](https://manifold.markets/Yev): M$5,000**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
🎈 *Awarded on 2022-06-14*
 | 
					🎈 *Awarded on 2022-06-14*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
 | 
					**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,7 +27,7 @@ service cloud.firestore {
 | 
				
			||||||
      allow read;
 | 
					      allow read;
 | 
				
			||||||
      allow update: if userId == request.auth.uid
 | 
					      allow update: if userId == request.auth.uid
 | 
				
			||||||
                       && request.resource.data.diff(resource.data).affectedKeys()
 | 
					                       && request.resource.data.diff(resource.data).affectedKeys()
 | 
				
			||||||
                                                                    .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
 | 
					                                                                    .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
 | 
				
			||||||
      // User referral rules
 | 
					      // User referral rules
 | 
				
			||||||
      allow update: if userId == request.auth.uid
 | 
					      allow update: if userId == request.auth.uid
 | 
				
			||||||
                         && request.resource.data.diff(resource.data).affectedKeys()
 | 
					                         && request.resource.data.diff(resource.data).affectedKeys()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										3
									
								
								functions/.env.dev
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								functions/.env.dev
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					# This sets which EnvConfig is deployed to Firebase Cloud Functions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					NEXT_PUBLIC_FIREBASE_ENV=DEV
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,7 @@
 | 
				
			||||||
    "firestore": "dev-mantic-markets.appspot.com"
 | 
					    "firestore": "dev-mantic-markets.appspot.com"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
 | 
					    "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
 | 
				
			||||||
    "compile": "tsc -b",
 | 
					    "compile": "tsc -b",
 | 
				
			||||||
    "watch": "tsc -w",
 | 
					    "watch": "tsc -w",
 | 
				
			||||||
    "shell": "yarn build && firebase functions:shell",
 | 
					    "shell": "yarn build && firebase functions:shell",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  } as EndpointDefinition
 | 
					  } as EndpointDefinition
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const newEndpointNoAuth = (
 | 
				
			||||||
 | 
					  endpointOpts: EndpointOptions,
 | 
				
			||||||
 | 
					  fn: (req: Request) => Promise<Output>
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    opts,
 | 
				
			||||||
 | 
					    handler: async (req: Request, res: Response) => {
 | 
				
			||||||
 | 
					      log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        if (opts.method !== req.method) {
 | 
				
			||||||
 | 
					          throw new APIError(405, `This endpoint supports only ${opts.method}.`)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        res.status(200).json(await fn(req))
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        writeResponseError(e, res)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  } as EndpointDefinition
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,7 +6,12 @@ import {
 | 
				
			||||||
  Notification,
 | 
					  Notification,
 | 
				
			||||||
  notification_reason_types,
 | 
					  notification_reason_types,
 | 
				
			||||||
} from '../../common/notification'
 | 
					} from '../../common/notification'
 | 
				
			||||||
import { User } from '../../common/user'
 | 
					import {
 | 
				
			||||||
 | 
					  MANIFOLD_AVATAR_URL,
 | 
				
			||||||
 | 
					  MANIFOLD_USER_NAME,
 | 
				
			||||||
 | 
					  MANIFOLD_USER_USERNAME,
 | 
				
			||||||
 | 
					  User,
 | 
				
			||||||
 | 
					} from '../../common/user'
 | 
				
			||||||
import { Contract } from '../../common/contract'
 | 
					import { Contract } from '../../common/contract'
 | 
				
			||||||
import { getPrivateUser, getValues } from './utils'
 | 
					import { getPrivateUser, getValues } from './utils'
 | 
				
			||||||
import { Comment } from '../../common/comment'
 | 
					import { Comment } from '../../common/comment'
 | 
				
			||||||
| 
						 | 
					@ -30,6 +35,7 @@ import {
 | 
				
			||||||
import { filterDefined } from '../../common/util/array'
 | 
					import { filterDefined } from '../../common/util/array'
 | 
				
			||||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
 | 
					import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
 | 
				
			||||||
import { ContractFollow } from '../../common/follow'
 | 
					import { ContractFollow } from '../../common/follow'
 | 
				
			||||||
 | 
					import { Badge } from 'common/badge'
 | 
				
			||||||
const firestore = admin.firestore()
 | 
					const firestore = admin.firestore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type recipients_to_reason_texts = {
 | 
					type recipients_to_reason_texts = {
 | 
				
			||||||
| 
						 | 
					@ -1087,6 +1093,43 @@ export const createBountyNotification = async (
 | 
				
			||||||
    sourceTitle: contract.question,
 | 
					    sourceTitle: contract.question,
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return await notificationRef.set(removeUndefinedProps(notification))
 | 
					  return await notificationRef.set(removeUndefinedProps(notification))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
  // maybe TODO: send email notification to comment creator
 | 
					
 | 
				
			||||||
 | 
					export const createBadgeAwardedNotification = async (
 | 
				
			||||||
 | 
					  user: User,
 | 
				
			||||||
 | 
					  badge: Badge
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const privateUser = await getPrivateUser(user.id)
 | 
				
			||||||
 | 
					  if (!privateUser) return
 | 
				
			||||||
 | 
					  const { sendToBrowser } = getNotificationDestinationsForUser(
 | 
				
			||||||
 | 
					    privateUser,
 | 
				
			||||||
 | 
					    'badges_awarded'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  if (!sendToBrowser) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const notificationRef = firestore
 | 
				
			||||||
 | 
					    .collection(`/users/${user.id}/notifications`)
 | 
				
			||||||
 | 
					    .doc()
 | 
				
			||||||
 | 
					  const notification: Notification = {
 | 
				
			||||||
 | 
					    id: notificationRef.id,
 | 
				
			||||||
 | 
					    userId: user.id,
 | 
				
			||||||
 | 
					    reason: 'badges_awarded',
 | 
				
			||||||
 | 
					    createdTime: Date.now(),
 | 
				
			||||||
 | 
					    isSeen: false,
 | 
				
			||||||
 | 
					    sourceId: badge.type,
 | 
				
			||||||
 | 
					    sourceType: 'badge',
 | 
				
			||||||
 | 
					    sourceUpdateType: 'created',
 | 
				
			||||||
 | 
					    sourceUserName: MANIFOLD_USER_NAME,
 | 
				
			||||||
 | 
					    sourceUserUsername: MANIFOLD_USER_USERNAME,
 | 
				
			||||||
 | 
					    sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
 | 
				
			||||||
 | 
					    sourceText: `You earned a new ${badge.name} badge!`,
 | 
				
			||||||
 | 
					    sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
 | 
				
			||||||
 | 
					    sourceTitle: badge.name,
 | 
				
			||||||
 | 
					    data: {
 | 
				
			||||||
 | 
					      badge,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return await notificationRef.set(removeUndefinedProps(notification))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // TODO send email notification
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
 | 
				
			||||||
    followedCategories: DEFAULT_CATEGORIES,
 | 
					    followedCategories: DEFAULT_CATEGORIES,
 | 
				
			||||||
    shouldShowWelcome: true,
 | 
					    shouldShowWelcome: true,
 | 
				
			||||||
    fractionResolvedCorrectly: 1,
 | 
					    fractionResolvedCorrectly: 1,
 | 
				
			||||||
 | 
					    achievements: {},
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await firestore.collection('users').doc(auth.uid).create(user)
 | 
					  await firestore.collection('users').doc(auth.uid).create(user)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
 | 
				
			||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
 | 
					import { formatNumericProbability } from '../../common/pseudo-numeric'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
 | 
					import { sendTemplateEmail, sendTextEmail } from './send-email'
 | 
				
			||||||
import { contractUrl, getUser } from './utils'
 | 
					import { contractUrl, getUser, log } from './utils'
 | 
				
			||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
 | 
					import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
 | 
				
			||||||
import { notification_reason_types } from '../../common/notification'
 | 
					import { notification_reason_types } from '../../common/notification'
 | 
				
			||||||
import { Dictionary } from 'lodash'
 | 
					import { Dictionary } from 'lodash'
 | 
				
			||||||
| 
						 | 
					@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
 | 
				
			||||||
  user: User,
 | 
					  user: User,
 | 
				
			||||||
  privateUser: PrivateUser
 | 
					  privateUser: PrivateUser
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  if (
 | 
					  if (!privateUser || !privateUser.email) return
 | 
				
			||||||
    !privateUser ||
 | 
					 | 
				
			||||||
    !privateUser.email ||
 | 
					 | 
				
			||||||
    !privateUser.notificationPreferences.onboarding_flow.includes('email')
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
    return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { name } = user
 | 
					  const { name } = user
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
					  const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
 | 
				
			||||||
    privateUser,
 | 
					    privateUser,
 | 
				
			||||||
    'onboarding_flow'
 | 
					    'onboarding_flow'
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					  if (!sendToEmail) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return await sendTemplateEmail(
 | 
					  return await sendTemplateEmail(
 | 
				
			||||||
    privateUser.email,
 | 
					    privateUser.email,
 | 
				
			||||||
| 
						 | 
					@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
 | 
				
			||||||
  privateUser: PrivateUser,
 | 
					  privateUser: PrivateUser,
 | 
				
			||||||
  sendTime: string
 | 
					  sendTime: string
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  if (
 | 
					  if (!privateUser || !privateUser.email) return
 | 
				
			||||||
    !privateUser ||
 | 
					 | 
				
			||||||
    !privateUser.email ||
 | 
					 | 
				
			||||||
    !privateUser.notificationPreferences.onboarding_flow.includes('email')
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
    return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { name } = user
 | 
					  const { name } = user
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
					  const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
 | 
				
			||||||
    privateUser,
 | 
					    privateUser,
 | 
				
			||||||
    'onboarding_flow'
 | 
					    'onboarding_flow'
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					  if (!sendToEmail) return
 | 
				
			||||||
  return await sendTemplateEmail(
 | 
					  return await sendTemplateEmail(
 | 
				
			||||||
    privateUser.email,
 | 
					    privateUser.email,
 | 
				
			||||||
    'Create your own prediction market',
 | 
					    'Create your own prediction market',
 | 
				
			||||||
| 
						 | 
					@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
 | 
				
			||||||
  user: User,
 | 
					  user: User,
 | 
				
			||||||
  privateUser: PrivateUser
 | 
					  privateUser: PrivateUser
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  if (
 | 
					  if (!privateUser || !privateUser.email) return
 | 
				
			||||||
    !privateUser ||
 | 
					 | 
				
			||||||
    !privateUser.email ||
 | 
					 | 
				
			||||||
    !privateUser.notificationPreferences.thank_you_for_purchases.includes(
 | 
					 | 
				
			||||||
      'email'
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
    return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { name } = user
 | 
					  const { name } = user
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
					  const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
 | 
				
			||||||
    privateUser,
 | 
					    privateUser,
 | 
				
			||||||
    'thank_you_for_purchases'
 | 
					    'thank_you_for_purchases'
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!sendToEmail) return
 | 
				
			||||||
  return await sendTemplateEmail(
 | 
					  return await sendTemplateEmail(
 | 
				
			||||||
    privateUser.email,
 | 
					    privateUser.email,
 | 
				
			||||||
    'Thanks for your Manifold purchase',
 | 
					    'Thanks for your Manifold purchase',
 | 
				
			||||||
| 
						 | 
					@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async (
 | 
				
			||||||
  contractsToSend: Contract[],
 | 
					  contractsToSend: Contract[],
 | 
				
			||||||
  deliveryTime?: string
 | 
					  deliveryTime?: string
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  if (
 | 
					  if (!privateUser || !privateUser.email) return
 | 
				
			||||||
    !privateUser ||
 | 
					 | 
				
			||||||
    !privateUser.email ||
 | 
					 | 
				
			||||||
    !privateUser.notificationPreferences.trending_markets.includes('email')
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
    return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
					  const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
 | 
				
			||||||
    privateUser,
 | 
					    privateUser,
 | 
				
			||||||
    'trending_markets'
 | 
					    'trending_markets'
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					  if (!sendToEmail) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { name } = user
 | 
					  const { name } = user
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
| 
						 | 
					@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
 | 
				
			||||||
  investments: PerContractInvestmentsData[],
 | 
					  investments: PerContractInvestmentsData[],
 | 
				
			||||||
  overallPerformance: OverallPerformanceData
 | 
					  overallPerformance: OverallPerformanceData
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  if (
 | 
					  if (!privateUser || !privateUser.email) return
 | 
				
			||||||
    !privateUser ||
 | 
					 | 
				
			||||||
    !privateUser.email ||
 | 
					 | 
				
			||||||
    !privateUser.notificationPreferences.profit_loss_updates.includes('email')
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
    return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { unsubscribeUrl } = getNotificationDestinationsForUser(
 | 
					  const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
 | 
				
			||||||
    privateUser,
 | 
					    privateUser,
 | 
				
			||||||
    'profit_loss_updates'
 | 
					    'profit_loss_updates'
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!sendToEmail) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { name } = user
 | 
					  const { name } = user
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
  const templateData: Record<string, string> = {
 | 
					  const templateData: Record<string, string> = {
 | 
				
			||||||
| 
						 | 
					@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
 | 
				
			||||||
      : 'portfolio-update',
 | 
					      : 'portfolio-update',
 | 
				
			||||||
    templateData
 | 
					    templateData
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					  log('Sent portfolio update email to', privateUser.email)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,7 +9,7 @@ export * from './on-create-user'
 | 
				
			||||||
export * from './on-create-bet'
 | 
					export * from './on-create-bet'
 | 
				
			||||||
export * from './on-create-comment-on-contract'
 | 
					export * from './on-create-comment-on-contract'
 | 
				
			||||||
export * from './on-view'
 | 
					export * from './on-view'
 | 
				
			||||||
export * from './update-metrics'
 | 
					export { scheduleUpdateMetrics } from './update-metrics'
 | 
				
			||||||
export * from './update-stats'
 | 
					export * from './update-stats'
 | 
				
			||||||
export * from './update-loans'
 | 
					export * from './update-loans'
 | 
				
			||||||
export * from './backup-db'
 | 
					export * from './backup-db'
 | 
				
			||||||
| 
						 | 
					@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user'
 | 
				
			||||||
import { acceptchallenge } from './accept-challenge'
 | 
					import { acceptchallenge } from './accept-challenge'
 | 
				
			||||||
import { createpost } from './create-post'
 | 
					import { createpost } from './create-post'
 | 
				
			||||||
import { savetwitchcredentials } from './save-twitch-credentials'
 | 
					import { savetwitchcredentials } from './save-twitch-credentials'
 | 
				
			||||||
 | 
					import { updatemetrics } from './update-metrics'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
 | 
					const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
 | 
				
			||||||
  return onRequest(opts, handler as any)
 | 
					  return onRequest(opts, handler as any)
 | 
				
			||||||
| 
						 | 
					@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
 | 
				
			||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
 | 
					const acceptChallenge = toCloudFunction(acceptchallenge)
 | 
				
			||||||
const createPostFunction = toCloudFunction(createpost)
 | 
					const createPostFunction = toCloudFunction(createpost)
 | 
				
			||||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
 | 
					const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
 | 
				
			||||||
 | 
					const updateMetricsFunction = toCloudFunction(updatemetrics)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export {
 | 
					export {
 | 
				
			||||||
  healthFunction as health,
 | 
					  healthFunction as health,
 | 
				
			||||||
| 
						 | 
					@ -133,4 +135,5 @@ export {
 | 
				
			||||||
  saveTwitchCredentials as savetwitchcredentials,
 | 
					  saveTwitchCredentials as savetwitchcredentials,
 | 
				
			||||||
  addCommentBounty as addcommentbounty,
 | 
					  addCommentBounty as addcommentbounty,
 | 
				
			||||||
  awardCommentBounty as awardcommentbounty,
 | 
					  awardCommentBounty as awardcommentbounty,
 | 
				
			||||||
 | 
					  updateMetricsFunction as updatemetrics,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,6 +12,7 @@ import {
 | 
				
			||||||
  revalidateStaticProps,
 | 
					  revalidateStaticProps,
 | 
				
			||||||
} from './utils'
 | 
					} from './utils'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  createBadgeAwardedNotification,
 | 
				
			||||||
  createBetFillNotification,
 | 
					  createBetFillNotification,
 | 
				
			||||||
  createBettingStreakBonusNotification,
 | 
					  createBettingStreakBonusNotification,
 | 
				
			||||||
  createUniqueBettorBonusNotification,
 | 
					  createUniqueBettorBonusNotification,
 | 
				
			||||||
| 
						 | 
					@ -33,6 +34,10 @@ import { APIError } from '../../common/api'
 | 
				
			||||||
import { User } from '../../common/user'
 | 
					import { User } from '../../common/user'
 | 
				
			||||||
import { DAY_MS } from '../../common/util/time'
 | 
					import { DAY_MS } from '../../common/util/time'
 | 
				
			||||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
 | 
					import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  StreakerBadge,
 | 
				
			||||||
 | 
					  streakerBadgeRarityThresholds,
 | 
				
			||||||
 | 
					} from '../../common/badge'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const firestore = admin.firestore()
 | 
					const firestore = admin.firestore()
 | 
				
			||||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
 | 
					const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
 | 
				
			||||||
| 
						 | 
					@ -143,7 +148,7 @@ const updateBettingStreak = async (
 | 
				
			||||||
    log('message:', result.message)
 | 
					    log('message:', result.message)
 | 
				
			||||||
    return
 | 
					    return
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  if (result.txn)
 | 
					  if (result.txn) {
 | 
				
			||||||
    await createBettingStreakBonusNotification(
 | 
					    await createBettingStreakBonusNotification(
 | 
				
			||||||
      user,
 | 
					      user,
 | 
				
			||||||
      result.txn.id,
 | 
					      result.txn.id,
 | 
				
			||||||
| 
						 | 
					@ -153,6 +158,8 @@ const updateBettingStreak = async (
 | 
				
			||||||
      newBettingStreak,
 | 
					      newBettingStreak,
 | 
				
			||||||
      eventId
 | 
					      eventId
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    await handleBettingStreakBadgeAward(user, newBettingStreak)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
 | 
					const updateUniqueBettorsAndGiveCreatorBonus = async (
 | 
				
			||||||
| 
						 | 
					@ -296,3 +303,39 @@ const notifyFills = async (
 | 
				
			||||||
const currentDateBettingStreakResetTime = () => {
 | 
					const currentDateBettingStreakResetTime = () => {
 | 
				
			||||||
  return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
 | 
					  return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handleBettingStreakBadgeAward(
 | 
				
			||||||
 | 
					  user: User,
 | 
				
			||||||
 | 
					  newBettingStreak: number
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  const alreadyHasBadgeForFirstStreak =
 | 
				
			||||||
 | 
					    user.achievements?.streaker?.badges.some(
 | 
				
			||||||
 | 
					      (badge) => badge.data.totalBettingStreak === 1
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  // TODO: check if already awarded 50th streak as well
 | 
				
			||||||
 | 
					  if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (newBettingStreak in streakerBadgeRarityThresholds) {
 | 
				
			||||||
 | 
					    const badge = {
 | 
				
			||||||
 | 
					      type: 'STREAKER',
 | 
				
			||||||
 | 
					      name: 'Streaker',
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        totalBettingStreak: newBettingStreak,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      createdTime: Date.now(),
 | 
				
			||||||
 | 
					    } as StreakerBadge
 | 
				
			||||||
 | 
					    // update user
 | 
				
			||||||
 | 
					    await firestore
 | 
				
			||||||
 | 
					      .collection('users')
 | 
				
			||||||
 | 
					      .doc(user.id)
 | 
				
			||||||
 | 
					      .update({
 | 
				
			||||||
 | 
					        achievements: {
 | 
				
			||||||
 | 
					          ...user.achievements,
 | 
				
			||||||
 | 
					          streaker: {
 | 
				
			||||||
 | 
					            badges: [...(user.achievements?.streaker?.badges ?? []), badge],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    await createBadgeAwardedNotification(user, badge)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,20 @@
 | 
				
			||||||
import * as functions from 'firebase-functions'
 | 
					import * as functions from 'firebase-functions'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { getUser } from './utils'
 | 
					import { getUser, getValues } from './utils'
 | 
				
			||||||
import { createNewContractNotification } from './create-notification'
 | 
					import {
 | 
				
			||||||
 | 
					  createBadgeAwardedNotification,
 | 
				
			||||||
 | 
					  createNewContractNotification,
 | 
				
			||||||
 | 
					} from './create-notification'
 | 
				
			||||||
import { Contract } from '../../common/contract'
 | 
					import { Contract } from '../../common/contract'
 | 
				
			||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
 | 
					import { parseMentions, richTextToString } from '../../common/util/parse'
 | 
				
			||||||
import { JSONContent } from '@tiptap/core'
 | 
					import { JSONContent } from '@tiptap/core'
 | 
				
			||||||
import { addUserToContractFollowers } from './follow-market'
 | 
					import { addUserToContractFollowers } from './follow-market'
 | 
				
			||||||
 | 
					import { User } from '../../common/user'
 | 
				
			||||||
 | 
					import * as admin from 'firebase-admin'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  MarketCreatorBadge,
 | 
				
			||||||
 | 
					  marketCreatorBadgeRarityThresholds,
 | 
				
			||||||
 | 
					} from '../../common/badge'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const onCreateContract = functions
 | 
					export const onCreateContract = functions
 | 
				
			||||||
  .runWith({ secrets: ['MAILGUN_KEY'] })
 | 
					  .runWith({ secrets: ['MAILGUN_KEY'] })
 | 
				
			||||||
| 
						 | 
					@ -28,4 +37,43 @@ export const onCreateContract = functions
 | 
				
			||||||
      richTextToString(desc),
 | 
					      richTextToString(desc),
 | 
				
			||||||
      mentioned
 | 
					      mentioned
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    await handleMarketCreatorBadgeAward(contractCreator)
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const firestore = admin.firestore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handleMarketCreatorBadgeAward(contractCreator: User) {
 | 
				
			||||||
 | 
					  // get all contracts by user and calculate size of array
 | 
				
			||||||
 | 
					  const contracts = await getValues<Contract>(
 | 
				
			||||||
 | 
					    firestore
 | 
				
			||||||
 | 
					      .collection(`contracts`)
 | 
				
			||||||
 | 
					      .where('creatorId', '==', contractCreator.id)
 | 
				
			||||||
 | 
					      .where('resolution', '!=', 'CANCEL')
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  if (contracts.length in marketCreatorBadgeRarityThresholds) {
 | 
				
			||||||
 | 
					    const badge = {
 | 
				
			||||||
 | 
					      type: 'MARKET_CREATOR',
 | 
				
			||||||
 | 
					      name: 'Market Creator',
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        totalContractsCreated: contracts.length,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      createdTime: Date.now(),
 | 
				
			||||||
 | 
					    } as MarketCreatorBadge
 | 
				
			||||||
 | 
					    // update user
 | 
				
			||||||
 | 
					    await firestore
 | 
				
			||||||
 | 
					      .collection('users')
 | 
				
			||||||
 | 
					      .doc(contractCreator.id)
 | 
				
			||||||
 | 
					      .update({
 | 
				
			||||||
 | 
					        achievements: {
 | 
				
			||||||
 | 
					          ...contractCreator.achievements,
 | 
				
			||||||
 | 
					          marketCreator: {
 | 
				
			||||||
 | 
					            badges: [
 | 
				
			||||||
 | 
					              ...(contractCreator.achievements?.marketCreator?.badges ?? []),
 | 
				
			||||||
 | 
					              badge,
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    await createBadgeAwardedNotification(contractCreator, badge)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,9 +1,19 @@
 | 
				
			||||||
import * as functions from 'firebase-functions'
 | 
					import * as functions from 'firebase-functions'
 | 
				
			||||||
import { getUser } from './utils'
 | 
					import { getUser, getValues } from './utils'
 | 
				
			||||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
 | 
					import {
 | 
				
			||||||
 | 
					  createBadgeAwardedNotification,
 | 
				
			||||||
 | 
					  createCommentOrAnswerOrUpdatedContractNotification,
 | 
				
			||||||
 | 
					} from './create-notification'
 | 
				
			||||||
import { Contract } from '../../common/contract'
 | 
					import { Contract } from '../../common/contract'
 | 
				
			||||||
import { GroupContractDoc } from '../../common/group'
 | 
					import { Bet } from '../../common/bet'
 | 
				
			||||||
import * as admin from 'firebase-admin'
 | 
					import * as admin from 'firebase-admin'
 | 
				
			||||||
 | 
					import { ContractComment } from '../../common/comment'
 | 
				
			||||||
 | 
					import { scoreCommentorsAndBettors } from '../../common/scoring'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
 | 
				
			||||||
 | 
					  ProvenCorrectBadge,
 | 
				
			||||||
 | 
					} from '../../common/badge'
 | 
				
			||||||
 | 
					import { GroupContractDoc } from '../../common/group'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const onUpdateContract = functions.firestore
 | 
					export const onUpdateContract = functions.firestore
 | 
				
			||||||
  .document('contracts/{contractId}')
 | 
					  .document('contracts/{contractId}')
 | 
				
			||||||
| 
						 | 
					@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!previousContract.isResolved && contract.isResolved) {
 | 
					    if (!previousContract.isResolved && contract.isResolved) {
 | 
				
			||||||
      // No need to notify users of resolution, that's handled in resolve-market
 | 
					      // No need to notify users of resolution, that's handled in resolve-market
 | 
				
			||||||
      return
 | 
					      return await handleResolvedContract(contract)
 | 
				
			||||||
    } else if (previousContract.groupSlugs !== contract.groupSlugs) {
 | 
					    } else if (previousContract.groupSlugs !== contract.groupSlugs) {
 | 
				
			||||||
      await handleContractGroupUpdated(previousContract, contract)
 | 
					      await handleContractGroupUpdated(previousContract, contract)
 | 
				
			||||||
    } else if (
 | 
					    } else if (
 | 
				
			||||||
| 
						 | 
					@ -26,6 +36,63 @@ export const onUpdateContract = functions.firestore
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handleResolvedContract(contract: Contract) {
 | 
				
			||||||
 | 
					  if (
 | 
				
			||||||
 | 
					    (contract.uniqueBettorCount ?? 0) <
 | 
				
			||||||
 | 
					    MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // get all bets on this contract
 | 
				
			||||||
 | 
					  const bets = await getValues<Bet>(
 | 
				
			||||||
 | 
					    firestore.collection(`contracts/${contract.id}/bets`)
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // get comments on this contract
 | 
				
			||||||
 | 
					  const comments = await getValues<ContractComment>(
 | 
				
			||||||
 | 
					    firestore.collection(`contracts/${contract.id}/comments`)
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
 | 
				
			||||||
 | 
					    scoreCommentorsAndBettors(contract, bets, comments)
 | 
				
			||||||
 | 
					  if (topCommentBetId && profitById[topCommentBetId] > 0) {
 | 
				
			||||||
 | 
					    // award proven correct badge to user
 | 
				
			||||||
 | 
					    const comment = commentsById[topCommentId]
 | 
				
			||||||
 | 
					    const bet = betsById[topCommentBetId]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const user = await getUser(comment.userId)
 | 
				
			||||||
 | 
					    if (!user) return
 | 
				
			||||||
 | 
					    const newProvenCorrectBadge = {
 | 
				
			||||||
 | 
					      createdTime: Date.now(),
 | 
				
			||||||
 | 
					      type: 'PROVEN_CORRECT',
 | 
				
			||||||
 | 
					      name: 'Proven Correct',
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        contractSlug: contract.slug,
 | 
				
			||||||
 | 
					        contractCreatorUsername: contract.creatorUsername,
 | 
				
			||||||
 | 
					        commentId: comment.id,
 | 
				
			||||||
 | 
					        betAmount: bet.amount,
 | 
				
			||||||
 | 
					        contractTitle: contract.question,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    } as ProvenCorrectBadge
 | 
				
			||||||
 | 
					    // update user
 | 
				
			||||||
 | 
					    await firestore
 | 
				
			||||||
 | 
					      .collection('users')
 | 
				
			||||||
 | 
					      .doc(user.id)
 | 
				
			||||||
 | 
					      .update({
 | 
				
			||||||
 | 
					        achievements: {
 | 
				
			||||||
 | 
					          ...user.achievements,
 | 
				
			||||||
 | 
					          provenCorrect: {
 | 
				
			||||||
 | 
					            badges: [
 | 
				
			||||||
 | 
					              ...(user.achievements?.provenCorrect?.badges ?? []),
 | 
				
			||||||
 | 
					              newProvenCorrectBadge,
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    await createBadgeAwardedNotification(user, newProvenCorrectBadge)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function handleUpdatedCloseTime(
 | 
					async function handleUpdatedCloseTime(
 | 
				
			||||||
  previousContract: Contract,
 | 
					  previousContract: Contract,
 | 
				
			||||||
  contract: Contract,
 | 
					  contract: Contract,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,15 +11,18 @@ async function main() {
 | 
				
			||||||
  await Promise.all(
 | 
					  await Promise.all(
 | 
				
			||||||
    privateUsers.map((privateUser) => {
 | 
					    privateUsers.map((privateUser) => {
 | 
				
			||||||
      if (!privateUser.id) return Promise.resolve()
 | 
					      if (!privateUser.id) return Promise.resolve()
 | 
				
			||||||
      return firestore
 | 
					      if (privateUser.notificationPreferences.badges_awarded === undefined) {
 | 
				
			||||||
        .collection('private-users')
 | 
					        return firestore
 | 
				
			||||||
        .doc(privateUser.id)
 | 
					          .collection('private-users')
 | 
				
			||||||
        .update({
 | 
					          .doc(privateUser.id)
 | 
				
			||||||
          notificationPreferences: {
 | 
					          .update({
 | 
				
			||||||
            ...privateUser.notificationPreferences,
 | 
					            notificationPreferences: {
 | 
				
			||||||
            opt_out_all: [],
 | 
					              ...privateUser.notificationPreferences,
 | 
				
			||||||
          },
 | 
					              badges_awarded: ['browser'],
 | 
				
			||||||
        })
 | 
					            },
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										102
									
								
								functions/src/scripts/backfill-badges.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								functions/src/scripts/backfill-badges.ts
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,102 @@
 | 
				
			||||||
 | 
					import * as admin from 'firebase-admin'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { initAdmin } from './script-init'
 | 
				
			||||||
 | 
					import { getAllUsers, getValues } from '../utils'
 | 
				
			||||||
 | 
					import { Contract } from 'common/contract'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  MarketCreatorBadge,
 | 
				
			||||||
 | 
					  marketCreatorBadgeRarityThresholds,
 | 
				
			||||||
 | 
					  StreakerBadge,
 | 
				
			||||||
 | 
					  streakerBadgeRarityThresholds,
 | 
				
			||||||
 | 
					} from 'common/badge'
 | 
				
			||||||
 | 
					import { User } from 'common/user'
 | 
				
			||||||
 | 
					initAdmin()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const firestore = admin.firestore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function main() {
 | 
				
			||||||
 | 
					  const users = await getAllUsers()
 | 
				
			||||||
 | 
					  // const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
 | 
				
			||||||
 | 
					  // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
 | 
				
			||||||
 | 
					  await Promise.all(
 | 
				
			||||||
 | 
					    users.map(async (user) => {
 | 
				
			||||||
 | 
					      if (!user.id) return
 | 
				
			||||||
 | 
					      // Only backfill users without achievements
 | 
				
			||||||
 | 
					      if (user.achievements === undefined) {
 | 
				
			||||||
 | 
					        await firestore.collection('users').doc(user.id).update({
 | 
				
			||||||
 | 
					          achievements: {},
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        user.achievements = {}
 | 
				
			||||||
 | 
					        user.achievements = await awardMarketCreatorBadges(user)
 | 
				
			||||||
 | 
					        user.achievements = await awardBettingStreakBadges(user)
 | 
				
			||||||
 | 
					        console.log('Added achievements to user', user.id)
 | 
				
			||||||
 | 
					        // going to ignore backfilling the proven correct badges for now
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if (require.main === module) main().then(() => process.exit())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function awardMarketCreatorBadges(user: User) {
 | 
				
			||||||
 | 
					  // Award market maker badges
 | 
				
			||||||
 | 
					  const contracts = await getValues<Contract>(
 | 
				
			||||||
 | 
					    firestore
 | 
				
			||||||
 | 
					      .collection(`contracts`)
 | 
				
			||||||
 | 
					      .where('creatorId', '==', user.id)
 | 
				
			||||||
 | 
					      .where('resolution', '!=', 'CANCEL')
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const achievements = {
 | 
				
			||||||
 | 
					    ...user.achievements,
 | 
				
			||||||
 | 
					    marketCreator: {
 | 
				
			||||||
 | 
					      badges: [...(user.achievements.marketCreator?.badges ?? [])],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  for (const threshold of marketCreatorBadgeRarityThresholds) {
 | 
				
			||||||
 | 
					    if (contracts.length >= threshold) {
 | 
				
			||||||
 | 
					      const badge = {
 | 
				
			||||||
 | 
					        type: 'MARKET_CREATOR',
 | 
				
			||||||
 | 
					        name: 'Market Creator',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          totalContractsCreated: threshold,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        createdTime: Date.now(),
 | 
				
			||||||
 | 
					      } as MarketCreatorBadge
 | 
				
			||||||
 | 
					      achievements.marketCreator.badges.push(badge)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // update user
 | 
				
			||||||
 | 
					  await firestore.collection('users').doc(user.id).update({
 | 
				
			||||||
 | 
					    achievements,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  return achievements
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function awardBettingStreakBadges(user: User) {
 | 
				
			||||||
 | 
					  const streak = user.currentBettingStreak ?? 0
 | 
				
			||||||
 | 
					  const achievements = {
 | 
				
			||||||
 | 
					    ...user.achievements,
 | 
				
			||||||
 | 
					    streaker: {
 | 
				
			||||||
 | 
					      badges: [...(user.achievements?.streaker?.badges ?? [])],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  for (const threshold of streakerBadgeRarityThresholds) {
 | 
				
			||||||
 | 
					    if (streak >= threshold) {
 | 
				
			||||||
 | 
					      const badge = {
 | 
				
			||||||
 | 
					        type: 'STREAKER',
 | 
				
			||||||
 | 
					        name: 'Streaker',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          totalBettingStreak: threshold,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        createdTime: Date.now(),
 | 
				
			||||||
 | 
					      } as StreakerBadge
 | 
				
			||||||
 | 
					      achievements.streaker.badges.push(badge)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // update user
 | 
				
			||||||
 | 
					  await firestore.collection('users').doc(user.id).update({
 | 
				
			||||||
 | 
					    achievements,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  return achievements
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,10 +1,11 @@
 | 
				
			||||||
import * as functions from 'firebase-functions'
 | 
					import * as functions from 'firebase-functions'
 | 
				
			||||||
import * as admin from 'firebase-admin'
 | 
					import * as admin from 'firebase-admin'
 | 
				
			||||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
 | 
					import { groupBy, keyBy, sortBy } from 'lodash'
 | 
				
			||||||
 | 
					import fetch from 'node-fetch'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { getValues, log, logMemory, writeAsync } from './utils'
 | 
					import { getValues, log, logMemory, writeAsync } from './utils'
 | 
				
			||||||
import { Bet } from '../../common/bet'
 | 
					import { Bet } from '../../common/bet'
 | 
				
			||||||
import { Contract, CPMM } from '../../common/contract'
 | 
					import { Contract, CPMM } from '../../common/contract'
 | 
				
			||||||
 | 
					 | 
				
			||||||
import { PortfolioMetrics, User } from '../../common/user'
 | 
					import { PortfolioMetrics, User } from '../../common/user'
 | 
				
			||||||
import { DAY_MS } from '../../common/util/time'
 | 
					import { DAY_MS } from '../../common/util/time'
 | 
				
			||||||
import { getLoanUpdates } from '../../common/loans'
 | 
					import { getLoanUpdates } from '../../common/loans'
 | 
				
			||||||
| 
						 | 
					@ -14,19 +15,44 @@ import {
 | 
				
			||||||
  calculateNewPortfolioMetrics,
 | 
					  calculateNewPortfolioMetrics,
 | 
				
			||||||
  calculateNewProfit,
 | 
					  calculateNewProfit,
 | 
				
			||||||
  calculateProbChanges,
 | 
					  calculateProbChanges,
 | 
				
			||||||
 | 
					  calculateMetricsByContract,
 | 
				
			||||||
  computeElasticity,
 | 
					  computeElasticity,
 | 
				
			||||||
  computeVolume,
 | 
					  computeVolume,
 | 
				
			||||||
} from '../../common/calculate-metrics'
 | 
					} from '../../common/calculate-metrics'
 | 
				
			||||||
import { getProbability } from '../../common/calculate'
 | 
					import { getProbability } from '../../common/calculate'
 | 
				
			||||||
import { Group } from '../../common/group'
 | 
					import { Group } from '../../common/group'
 | 
				
			||||||
import { batchedWaitAll } from '../../common/util/promise'
 | 
					import { batchedWaitAll } from '../../common/util/promise'
 | 
				
			||||||
 | 
					import { newEndpointNoAuth } from './api'
 | 
				
			||||||
 | 
					import { getFunctionUrl } from '../../common/api'
 | 
				
			||||||
 | 
					import { filterDefined } from '../../common/util/array'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const firestore = admin.firestore()
 | 
					const firestore = admin.firestore()
 | 
				
			||||||
 | 
					export const scheduleUpdateMetrics = functions.pubsub
 | 
				
			||||||
 | 
					  .schedule('every 15 minutes')
 | 
				
			||||||
 | 
					  .onRun(async () => {
 | 
				
			||||||
 | 
					    const url = getFunctionUrl('updatemetrics')
 | 
				
			||||||
 | 
					    console.log('Scheduling update metrics', url)
 | 
				
			||||||
 | 
					    const response = await fetch(url, {
 | 
				
			||||||
 | 
					      headers: {
 | 
				
			||||||
 | 
					        'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      body: JSON.stringify({}),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const updateMetrics = functions
 | 
					    const json = await response.json()
 | 
				
			||||||
  .runWith({ memory: '8GB', timeoutSeconds: 540 })
 | 
					
 | 
				
			||||||
  .pubsub.schedule('every 15 minutes')
 | 
					    if (response.ok) console.log(json)
 | 
				
			||||||
  .onRun(updateMetricsCore)
 | 
					    else console.error(json)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const updatemetrics = newEndpointNoAuth(
 | 
				
			||||||
 | 
					  { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
 | 
				
			||||||
 | 
					  async (_req) => {
 | 
				
			||||||
 | 
					    await updateMetricsCore()
 | 
				
			||||||
 | 
					    return { success: true }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function updateMetricsCore() {
 | 
					export async function updateMetricsCore() {
 | 
				
			||||||
  console.log('Loading users')
 | 
					  console.log('Loading users')
 | 
				
			||||||
| 
						 | 
					@ -36,11 +62,7 @@ export async function updateMetricsCore() {
 | 
				
			||||||
  const contracts = await getValues<Contract>(firestore.collection('contracts'))
 | 
					  const contracts = await getValues<Contract>(firestore.collection('contracts'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  console.log('Loading portfolio history')
 | 
					  console.log('Loading portfolio history')
 | 
				
			||||||
  const allPortfolioHistories = await getValues<PortfolioMetrics>(
 | 
					  const userPortfolioHistory = await loadPortfolioHistory(users)
 | 
				
			||||||
    firestore
 | 
					 | 
				
			||||||
      .collectionGroup('portfolioHistory')
 | 
					 | 
				
			||||||
      .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  console.log('Loading groups')
 | 
					  console.log('Loading groups')
 | 
				
			||||||
  const groups = await getValues<Group>(firestore.collection('groups'))
 | 
					  const groups = await getValues<Group>(firestore.collection('groups'))
 | 
				
			||||||
| 
						 | 
					@ -117,11 +139,10 @@ export async function updateMetricsCore() {
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
  const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
 | 
					  const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
 | 
				
			||||||
  const betsByUser = groupBy(bets, (bet) => bet.userId)
 | 
					  const betsByUser = groupBy(bets, (bet) => bet.userId)
 | 
				
			||||||
  const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const userMetrics = users.map((user) => {
 | 
					  const userMetrics = users.map((user) => {
 | 
				
			||||||
    const currentBets = betsByUser[user.id] ?? []
 | 
					    const currentBets = betsByUser[user.id] ?? []
 | 
				
			||||||
    const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
 | 
					    const portfolioHistory = userPortfolioHistory[user.id] ?? []
 | 
				
			||||||
    const userContracts = contractsByUser[user.id] ?? []
 | 
					    const userContracts = contractsByUser[user.id] ?? []
 | 
				
			||||||
    const newCreatorVolume = calculateCreatorVolume(userContracts)
 | 
					    const newCreatorVolume = calculateCreatorVolume(userContracts)
 | 
				
			||||||
    const newPortfolio = calculateNewPortfolioMetrics(
 | 
					    const newPortfolio = calculateNewPortfolioMetrics(
 | 
				
			||||||
| 
						 | 
					@ -129,14 +150,20 @@ export async function updateMetricsCore() {
 | 
				
			||||||
      contractsById,
 | 
					      contractsById,
 | 
				
			||||||
      currentBets
 | 
					      currentBets
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    const lastPortfolio = last(portfolioHistory)
 | 
					    const currPortfolio = portfolioHistory.current
 | 
				
			||||||
    const didPortfolioChange =
 | 
					    const didPortfolioChange =
 | 
				
			||||||
      lastPortfolio === undefined ||
 | 
					      currPortfolio === undefined ||
 | 
				
			||||||
      lastPortfolio.balance !== newPortfolio.balance ||
 | 
					      currPortfolio.balance !== newPortfolio.balance ||
 | 
				
			||||||
      lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
 | 
					      currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
 | 
				
			||||||
      lastPortfolio.investmentValue !== newPortfolio.investmentValue
 | 
					      currPortfolio.investmentValue !== newPortfolio.investmentValue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
 | 
					    const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const metricsByContract = calculateMetricsByContract(
 | 
				
			||||||
 | 
					      currentBets,
 | 
				
			||||||
 | 
					      contractsById
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const contractRatios = userContracts
 | 
					    const contractRatios = userContracts
 | 
				
			||||||
      .map((contract) => {
 | 
					      .map((contract) => {
 | 
				
			||||||
        if (
 | 
					        if (
 | 
				
			||||||
| 
						 | 
					@ -146,7 +173,7 @@ export async function updateMetricsCore() {
 | 
				
			||||||
          return 0
 | 
					          return 0
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        const contractRatio =
 | 
					        const contractRatio =
 | 
				
			||||||
          contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
 | 
					          contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return contractRatio
 | 
					        return contractRatio
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
| 
						 | 
					@ -167,6 +194,7 @@ export async function updateMetricsCore() {
 | 
				
			||||||
      newProfit,
 | 
					      newProfit,
 | 
				
			||||||
      didPortfolioChange,
 | 
					      didPortfolioChange,
 | 
				
			||||||
      newFractionResolvedCorrectly,
 | 
					      newFractionResolvedCorrectly,
 | 
				
			||||||
 | 
					      metricsByContract,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -182,63 +210,61 @@ export async function updateMetricsCore() {
 | 
				
			||||||
  const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
 | 
					  const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const userUpdates = userMetrics.map(
 | 
					  const userUpdates = userMetrics.map(
 | 
				
			||||||
    ({
 | 
					    ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
 | 
				
			||||||
      user,
 | 
					 | 
				
			||||||
      newCreatorVolume,
 | 
					 | 
				
			||||||
      newPortfolio,
 | 
					 | 
				
			||||||
      newProfit,
 | 
					 | 
				
			||||||
      didPortfolioChange,
 | 
					 | 
				
			||||||
      newFractionResolvedCorrectly,
 | 
					 | 
				
			||||||
    }) => {
 | 
					 | 
				
			||||||
      const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
 | 
					      const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        fieldUpdates: {
 | 
					        doc: firestore.collection('users').doc(user.id),
 | 
				
			||||||
          doc: firestore.collection('users').doc(user.id),
 | 
					        fields: {
 | 
				
			||||||
          fields: {
 | 
					          creatorVolumeCached: newCreatorVolume,
 | 
				
			||||||
            creatorVolumeCached: newCreatorVolume,
 | 
					          profitCached: newProfit,
 | 
				
			||||||
            profitCached: newProfit,
 | 
					          nextLoanCached,
 | 
				
			||||||
            nextLoanCached,
 | 
					          fractionResolvedCorrectly: newFractionResolvedCorrectly,
 | 
				
			||||||
            fractionResolvedCorrectly: newFractionResolvedCorrectly,
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        subcollectionUpdates: {
 | 
					 | 
				
			||||||
          doc: firestore
 | 
					 | 
				
			||||||
            .collection('users')
 | 
					 | 
				
			||||||
            .doc(user.id)
 | 
					 | 
				
			||||||
            .collection('portfolioHistory')
 | 
					 | 
				
			||||||
            .doc(),
 | 
					 | 
				
			||||||
          fields: didPortfolioChange ? newPortfolio : {},
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
  await writeAsync(
 | 
					  await writeAsync(firestore, userUpdates)
 | 
				
			||||||
    firestore,
 | 
					
 | 
				
			||||||
    userUpdates.map((u) => u.fieldUpdates)
 | 
					  const portfolioHistoryUpdates = filterDefined(
 | 
				
			||||||
 | 
					    userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
 | 
				
			||||||
 | 
					      return didPortfolioChange
 | 
				
			||||||
 | 
					        ? {
 | 
				
			||||||
 | 
					            doc: firestore
 | 
				
			||||||
 | 
					              .collection('users')
 | 
				
			||||||
 | 
					              .doc(user.id)
 | 
				
			||||||
 | 
					              .collection('portfolioHistory')
 | 
				
			||||||
 | 
					              .doc(),
 | 
				
			||||||
 | 
					            fields: newPortfolio,
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        : null
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
  await writeAsync(
 | 
					  await writeAsync(firestore, portfolioHistoryUpdates, 'set')
 | 
				
			||||||
    firestore,
 | 
					
 | 
				
			||||||
    userUpdates
 | 
					  const contractMetricsUpdates = userMetrics.flatMap(
 | 
				
			||||||
      .filter((u) => !isEmpty(u.subcollectionUpdates.fields))
 | 
					    ({ user, metricsByContract }) => {
 | 
				
			||||||
      .map((u) => u.subcollectionUpdates),
 | 
					      const collection = firestore
 | 
				
			||||||
    'set'
 | 
					        .collection('users')
 | 
				
			||||||
 | 
					        .doc(user.id)
 | 
				
			||||||
 | 
					        .collection('contract-metrics')
 | 
				
			||||||
 | 
					      return metricsByContract.map((metrics) => ({
 | 
				
			||||||
 | 
					        doc: collection.doc(metrics.contractId),
 | 
				
			||||||
 | 
					        fields: metrics,
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await writeAsync(firestore, contractMetricsUpdates, 'set')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  log(`Updated metrics for ${users.length} users.`)
 | 
					  log(`Updated metrics for ${users.length} users.`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const groupUpdates = groups.map((group, index) => {
 | 
					    const groupUpdates = groups.map((group, index) => {
 | 
				
			||||||
      const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
 | 
					      const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
 | 
				
			||||||
      const groupContracts = groupContractIds
 | 
					      const groupContracts = filterDefined(
 | 
				
			||||||
        .map((e) => contractsById[e.contractId])
 | 
					        groupContractIds.map((e) => contractsById[e.contractId])
 | 
				
			||||||
        .filter((e) => e !== undefined) as Contract[]
 | 
					      )
 | 
				
			||||||
      const bets = groupContracts.map((e) => {
 | 
					      const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
 | 
				
			||||||
        if (e != null && e.id in betsByContract) {
 | 
					 | 
				
			||||||
          return betsByContract[e.id] ?? []
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          return []
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const creatorScores = scoreCreators(groupContracts)
 | 
					      const creatorScores = scoreCreators(groupContracts)
 | 
				
			||||||
      const traderScores = scoreTraders(groupContracts, bets)
 | 
					      const traderScores = scoreTraders(groupContracts, bets)
 | 
				
			||||||
| 
						 | 
					@ -272,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
 | 
				
			||||||
type GroupContractDoc = { contractId: string; createdTime: number }
 | 
					type GroupContractDoc = { contractId: string; createdTime: number }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const BAD_RESOLUTION_THRESHOLD = 0.1
 | 
					const BAD_RESOLUTION_THRESHOLD = 0.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loadPortfolioHistory = async (users: User[]) => {
 | 
				
			||||||
 | 
					  const now = Date.now()
 | 
				
			||||||
 | 
					  const userPortfolioHistory = await batchedWaitAll(
 | 
				
			||||||
 | 
					    users.map((user) => async () => {
 | 
				
			||||||
 | 
					      const query = firestore
 | 
				
			||||||
 | 
					        .collection('users')
 | 
				
			||||||
 | 
					        .doc(user.id)
 | 
				
			||||||
 | 
					        .collection('portfolioHistory')
 | 
				
			||||||
 | 
					        .orderBy('timestamp', 'desc')
 | 
				
			||||||
 | 
					        .limit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const portfolioMetrics = await Promise.all([
 | 
				
			||||||
 | 
					        getValues<PortfolioMetrics>(query),
 | 
				
			||||||
 | 
					        getValues<PortfolioMetrics>(
 | 
				
			||||||
 | 
					          query.where('timestamp', '<', now - DAY_MS)
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        getValues<PortfolioMetrics>(
 | 
				
			||||||
 | 
					          query.where('timestamp', '<', now - 7 * DAY_MS)
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        getValues<PortfolioMetrics>(
 | 
				
			||||||
 | 
					          query.where('timestamp', '<', now - 30 * DAY_MS)
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					      const [current, day, week, month] = portfolioMetrics.map(
 | 
				
			||||||
 | 
					        (p) => p[0] as PortfolioMetrics | undefined
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        userId: user.id,
 | 
				
			||||||
 | 
					        current,
 | 
				
			||||||
 | 
					        day,
 | 
				
			||||||
 | 
					        week,
 | 
				
			||||||
 | 
					        month,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    100
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return keyBy(userPortfolioHistory, (p) => p.userId)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
 | 
				
			||||||
  return users.docs.map((doc) => doc.data() as PrivateUser)
 | 
					  return users.docs.map((doc) => doc.data() as PrivateUser)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getAllUsers = async () => {
 | 
				
			||||||
 | 
					  const firestore = admin.firestore()
 | 
				
			||||||
 | 
					  const users = await firestore.collection('users').get()
 | 
				
			||||||
 | 
					  return users.docs.map((doc) => doc.data() as User)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getUserByUsername = async (username: string) => {
 | 
					export const getUserByUsername = async (username: string) => {
 | 
				
			||||||
  const firestore = admin.firestore()
 | 
					  const firestore = admin.firestore()
 | 
				
			||||||
  const snap = await firestore
 | 
					  const snap = await firestore
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
  log('Found', contractsUsersBetOn.length, 'contracts')
 | 
					 | 
				
			||||||
  let count = 0
 | 
					 | 
				
			||||||
  await Promise.all(
 | 
					  await Promise.all(
 | 
				
			||||||
    privateUsersToSendEmailsTo.map(async (privateUser) => {
 | 
					    privateUsersToSendEmailsTo.map(async (privateUser) => {
 | 
				
			||||||
      const user = await getUser(privateUser.id)
 | 
					      const user = await getUser(privateUser.id)
 | 
				
			||||||
      // Don't send to a user unless they're over 5 days old
 | 
					      // Don't send to a user unless they're over 5 days old
 | 
				
			||||||
      if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
 | 
					      if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
 | 
				
			||||||
 | 
					        return await setEmailFlagAsSent(privateUser.id)
 | 
				
			||||||
      const userBets = usersBets[privateUser.id] as Bet[]
 | 
					      const userBets = usersBets[privateUser.id] as Bet[]
 | 
				
			||||||
      const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
 | 
					      const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
 | 
				
			||||||
        userBets.some((bet) => bet.contractId === contract.id)
 | 
					        userBets.some((bet) => bet.contractId === contract.id)
 | 
				
			||||||
| 
						 | 
					@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
 | 
				
			||||||
        (differences) => Math.abs(differences.profit)
 | 
					        (differences) => Math.abs(differences.profit)
 | 
				
			||||||
      ).reverse()
 | 
					      ).reverse()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      log(
 | 
					 | 
				
			||||||
        'Found',
 | 
					 | 
				
			||||||
        investmentValueDifferences.length,
 | 
					 | 
				
			||||||
        'investment differences for user',
 | 
					 | 
				
			||||||
        privateUser.id
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const [winningInvestments, losingInvestments] = partition(
 | 
					      const [winningInvestments, losingInvestments] = partition(
 | 
				
			||||||
        investmentValueDifferences.filter(
 | 
					        investmentValueDifferences.filter(
 | 
				
			||||||
          (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
 | 
					          (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
 | 
				
			||||||
| 
						 | 
					@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
 | 
				
			||||||
        usersToContractsCreated[privateUser.id].length === 0
 | 
					        usersToContractsCreated[privateUser.id].length === 0
 | 
				
			||||||
      ) {
 | 
					      ) {
 | 
				
			||||||
        log(
 | 
					        log(
 | 
				
			||||||
          'No bets in last week, no market movers, no markets created. Not sending an email.'
 | 
					          `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        await firestore.collection('private-users').doc(privateUser.id).update({
 | 
					        return await setEmailFlagAsSent(privateUser.id)
 | 
				
			||||||
          weeklyPortfolioUpdateEmailSent: true,
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      // Set the flag beforehand just to be safe
 | 
				
			||||||
 | 
					      await setEmailFlagAsSent(privateUser.id)
 | 
				
			||||||
      await sendWeeklyPortfolioUpdateEmail(
 | 
					      await sendWeeklyPortfolioUpdateEmail(
 | 
				
			||||||
        user,
 | 
					        user,
 | 
				
			||||||
        privateUser,
 | 
					        privateUser,
 | 
				
			||||||
        topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
 | 
					        topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
 | 
				
			||||||
        performanceData
 | 
					        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)
 | 
					 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function setEmailFlagAsSent(privateUserId: string) {
 | 
				
			||||||
 | 
					  await firestore.collection('private-users').doc(privateUserId).update({
 | 
				
			||||||
 | 
					    weeklyPortfolioUpdateEmailSent: true,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type PerContractInvestmentsData = {
 | 
					export type PerContractInvestmentsData = {
 | 
				
			||||||
  questionTitle: string
 | 
					  questionTitle: string
 | 
				
			||||||
  questionUrl: string
 | 
					  questionUrl: string
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ import { Col } from './layout/col'
 | 
				
			||||||
import { ENV_CONFIG } from 'common/envs/constants'
 | 
					import { ENV_CONFIG } from 'common/envs/constants'
 | 
				
			||||||
import { Row } from './layout/row'
 | 
					import { Row } from './layout/row'
 | 
				
			||||||
import { AddFundsModal } from './add-funds-modal'
 | 
					import { AddFundsModal } from './add-funds-modal'
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function AmountInput(props: {
 | 
					export function AmountInput(props: {
 | 
				
			||||||
  amount: number | undefined
 | 
					  amount: number | undefined
 | 
				
			||||||
| 
						 | 
					@ -44,9 +45,9 @@ export function AmountInput(props: {
 | 
				
			||||||
          <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
 | 
					          <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
 | 
				
			||||||
            {label}
 | 
					            {label}
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            className={clsx(
 | 
					            className={clsx(
 | 
				
			||||||
              'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
 | 
					              'pl-9',
 | 
				
			||||||
              error && 'input-error',
 | 
					              error && 'input-error',
 | 
				
			||||||
              'w-24 md:w-auto',
 | 
					              'w-24 md:w-auto',
 | 
				
			||||||
              inputClassName
 | 
					              inputClassName
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
 | 
				
			||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
 | 
					import { getDpmOutcomeProbability } from 'common/calculate-dpm'
 | 
				
			||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
 | 
					import { tradingAllowed } from 'web/lib/firebase/contracts'
 | 
				
			||||||
import { Linkify } from '../linkify'
 | 
					import { Linkify } from '../linkify'
 | 
				
			||||||
 | 
					import { Input } from '../input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function AnswerItem(props: {
 | 
					export function AnswerItem(props: {
 | 
				
			||||||
  answer: Answer
 | 
					  answer: Answer
 | 
				
			||||||
| 
						 | 
					@ -74,8 +75,8 @@ export function AnswerItem(props: {
 | 
				
			||||||
      <Row className="items-center justify-end gap-4 self-end sm:self-start">
 | 
					      <Row className="items-center justify-end gap-4 self-end sm:self-start">
 | 
				
			||||||
        {!wasResolvedTo &&
 | 
					        {!wasResolvedTo &&
 | 
				
			||||||
          (showChoice === 'checkbox' ? (
 | 
					          (showChoice === 'checkbox' ? (
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              className="input input-bordered w-24 justify-self-end text-2xl"
 | 
					              className="w-24 justify-self-end !text-2xl"
 | 
				
			||||||
              type="number"
 | 
					              type="number"
 | 
				
			||||||
              placeholder={`${roundedProb}`}
 | 
					              placeholder={`${roundedProb}`}
 | 
				
			||||||
              maxLength={9}
 | 
					              maxLength={9}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,5 @@
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import React, { useState } from 'react'
 | 
					import React, { useState } from 'react'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import { findBestMatch } from 'string-similarity'
 | 
					import { findBestMatch } from 'string-similarity'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { FreeResponseContract } from 'common/contract'
 | 
					import { FreeResponseContract } from 'common/contract'
 | 
				
			||||||
| 
						 | 
					@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
 | 
				
			||||||
import { withTracking } from 'web/lib/service/analytics'
 | 
					import { withTracking } from 'web/lib/service/analytics'
 | 
				
			||||||
import { lowerCase } from 'lodash'
 | 
					import { lowerCase } from 'lodash'
 | 
				
			||||||
import { Button } from '../button'
 | 
					import { Button } from '../button'
 | 
				
			||||||
 | 
					import { ExpandingInput } from '../expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
 | 
					export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
 | 
				
			||||||
  const { contract } = props
 | 
					  const { contract } = props
 | 
				
			||||||
| 
						 | 
					@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
 | 
				
			||||||
    <Col className="gap-4 rounded">
 | 
					    <Col className="gap-4 rounded">
 | 
				
			||||||
      <Col className="flex-1 gap-2 px-4 xl:px-0">
 | 
					      <Col className="flex-1 gap-2 px-4 xl:px-0">
 | 
				
			||||||
        <div className="mb-1">Add your answer</div>
 | 
					        <div className="mb-1">Add your answer</div>
 | 
				
			||||||
        <Textarea
 | 
					        <ExpandingInput
 | 
				
			||||||
          value={text}
 | 
					          value={text}
 | 
				
			||||||
          onChange={(e) => changeAnswer(e.target.value)}
 | 
					          onChange={(e) => changeAnswer(e.target.value)}
 | 
				
			||||||
          className="textarea textarea-bordered w-full resize-none"
 | 
					          className="w-full"
 | 
				
			||||||
          placeholder="Type your answer..."
 | 
					          placeholder="Type your answer..."
 | 
				
			||||||
          rows={1}
 | 
					          rows={1}
 | 
				
			||||||
          maxLength={MAX_ANSWER_LENGTH}
 | 
					          maxLength={MAX_ANSWER_LENGTH}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,8 @@
 | 
				
			||||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
 | 
					import { MAX_ANSWER_LENGTH } from 'common/answer'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import { XIcon } from '@heroicons/react/solid'
 | 
					import { XIcon } from '@heroicons/react/solid'
 | 
				
			||||||
import { Col } from '../layout/col'
 | 
					import { Col } from '../layout/col'
 | 
				
			||||||
import { Row } from '../layout/row'
 | 
					import { Row } from '../layout/row'
 | 
				
			||||||
 | 
					import { ExpandingInput } from '../expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function MultipleChoiceAnswers(props: {
 | 
					export function MultipleChoiceAnswers(props: {
 | 
				
			||||||
  answers: string[]
 | 
					  answers: string[]
 | 
				
			||||||
| 
						 | 
					@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
 | 
				
			||||||
      {answers.map((answer, i) => (
 | 
					      {answers.map((answer, i) => (
 | 
				
			||||||
        <Row className="mb-2 items-center gap-2 align-middle">
 | 
					        <Row className="mb-2 items-center gap-2 align-middle">
 | 
				
			||||||
          {i + 1}.{' '}
 | 
					          {i + 1}.{' '}
 | 
				
			||||||
          <Textarea
 | 
					          <ExpandingInput
 | 
				
			||||||
            value={answer}
 | 
					            value={answer}
 | 
				
			||||||
            onChange={(e) => setAnswer(i, e.target.value)}
 | 
					            onChange={(e) => setAnswer(i, e.target.value)}
 | 
				
			||||||
            className="textarea textarea-bordered ml-2 w-full resize-none"
 | 
					            className="ml-2 w-full"
 | 
				
			||||||
            placeholder="Type your answer..."
 | 
					            placeholder="Type your answer..."
 | 
				
			||||||
            rows={1}
 | 
					            rows={1}
 | 
				
			||||||
            maxLength={MAX_ANSWER_LENGTH}
 | 
					            maxLength={MAX_ANSWER_LENGTH}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										62
									
								
								web/components/badge-display.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								web/components/badge-display.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,62 @@
 | 
				
			||||||
 | 
					import { User } from 'common/user'
 | 
				
			||||||
 | 
					import { useEffect, useState } from 'react'
 | 
				
			||||||
 | 
					import { getBadgesByRarity } from 'common/badge'
 | 
				
			||||||
 | 
					import { Row } from 'web/components/layout/row'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { BadgesModal } from 'web/components/profile/badges-modal'
 | 
				
			||||||
 | 
					import { ParsedUrlQuery } from 'querystring'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const goldClassName = 'text-amber-400'
 | 
				
			||||||
 | 
					export const silverClassName = 'text-gray-500'
 | 
				
			||||||
 | 
					export const bronzeClassName = 'text-amber-900'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function BadgeDisplay(props: {
 | 
				
			||||||
 | 
					  user: User | undefined | null
 | 
				
			||||||
 | 
					  query: ParsedUrlQuery
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { user, query } = props
 | 
				
			||||||
 | 
					  const [showBadgesModal, setShowBadgesModal] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const showBadgesModal = query['show'] == 'badges'
 | 
				
			||||||
 | 
					    setShowBadgesModal(showBadgesModal)
 | 
				
			||||||
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					  }, [])
 | 
				
			||||||
 | 
					  // get number of badges of each rarity type
 | 
				
			||||||
 | 
					  const badgesByRarity = getBadgesByRarity(user)
 | 
				
			||||||
 | 
					  const badgesByRarityItems = Object.entries(badgesByRarity).map(
 | 
				
			||||||
 | 
					    ([rarity, numBadges]) => {
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
 | 
					        <Row
 | 
				
			||||||
 | 
					          key={rarity}
 | 
				
			||||||
 | 
					          className={clsx(
 | 
				
			||||||
 | 
					            'items-center gap-2',
 | 
				
			||||||
 | 
					            rarity === 'bronze'
 | 
				
			||||||
 | 
					              ? bronzeClassName
 | 
				
			||||||
 | 
					              : rarity === 'silver'
 | 
				
			||||||
 | 
					              ? silverClassName
 | 
				
			||||||
 | 
					              : goldClassName
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <span className={clsx('-m-0.5 text-lg')}>•</span>
 | 
				
			||||||
 | 
					          <span className="text-xs">{numBadges}</span>
 | 
				
			||||||
 | 
					        </Row>
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Row
 | 
				
			||||||
 | 
					      className={'cursor-pointer gap-2'}
 | 
				
			||||||
 | 
					      onClick={() => setShowBadgesModal(true)}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {badgesByRarityItems}
 | 
				
			||||||
 | 
					      {user && (
 | 
				
			||||||
 | 
					        <BadgesModal
 | 
				
			||||||
 | 
					          isOpen={showBadgesModal}
 | 
				
			||||||
 | 
					          setOpen={setShowBadgesModal}
 | 
				
			||||||
 | 
					          user={user}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </Row>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -20,10 +20,10 @@ import { getProbability } from 'common/calculate'
 | 
				
			||||||
import { createMarket } from 'web/lib/firebase/api'
 | 
					import { createMarket } from 'web/lib/firebase/api'
 | 
				
			||||||
import { removeUndefinedProps } from 'common/util/object'
 | 
					import { removeUndefinedProps } from 'common/util/object'
 | 
				
			||||||
import { FIXED_ANTE } from 'common/economy'
 | 
					import { FIXED_ANTE } from 'common/economy'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
 | 
					import { LoadingIndicator } from 'web/components/loading-indicator'
 | 
				
			||||||
import { track } from 'web/lib/service/analytics'
 | 
					import { track } from 'web/lib/service/analytics'
 | 
				
			||||||
import { CopyLinkButton } from '../copy-link-button'
 | 
					import { CopyLinkButton } from '../copy-link-button'
 | 
				
			||||||
 | 
					import { ExpandingInput } from '../expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type challengeInfo = {
 | 
					type challengeInfo = {
 | 
				
			||||||
  amount: number
 | 
					  amount: number
 | 
				
			||||||
| 
						 | 
					@ -150,9 +150,9 @@ function CreateChallengeForm(props: {
 | 
				
			||||||
            {contract ? (
 | 
					            {contract ? (
 | 
				
			||||||
              <span className="underline">{contract.question}</span>
 | 
					              <span className="underline">{contract.question}</span>
 | 
				
			||||||
            ) : (
 | 
					            ) : (
 | 
				
			||||||
              <Textarea
 | 
					              <ExpandingInput
 | 
				
			||||||
                placeholder="e.g. Will a Democrat be the next president?"
 | 
					                placeholder="e.g. Will a Democrat be the next president?"
 | 
				
			||||||
                className="input input-bordered mt-1 w-full resize-none"
 | 
					                className="mt-1 w-full"
 | 
				
			||||||
                autoFocus={true}
 | 
					                autoFocus={true}
 | 
				
			||||||
                maxLength={MAX_QUESTION_LENGTH}
 | 
					                maxLength={MAX_QUESTION_LENGTH}
 | 
				
			||||||
                value={challengeInfo.question}
 | 
					                value={challengeInfo.question}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
 | 
				
			||||||
import { Button } from './button'
 | 
					import { Button } from './button'
 | 
				
			||||||
import { Modal } from './layout/modal'
 | 
					import { Modal } from './layout/modal'
 | 
				
			||||||
import { Title } from './title'
 | 
					import { Title } from './title'
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SORTS = [
 | 
					export const SORTS = [
 | 
				
			||||||
  { label: 'Newest', value: 'newest' },
 | 
					  { label: 'Newest', value: 'newest' },
 | 
				
			||||||
| 
						 | 
					@ -438,13 +439,13 @@ function ContractSearchControls(props: {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
 | 
					    <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
 | 
				
			||||||
      <Row className="gap-1 sm:gap-2">
 | 
					      <Row className="gap-1 sm:gap-2">
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          value={query}
 | 
					          value={query}
 | 
				
			||||||
          onChange={(e) => updateQuery(e.target.value)}
 | 
					          onChange={(e) => updateQuery(e.target.value)}
 | 
				
			||||||
          onBlur={trackCallback('search', { query: query })}
 | 
					          onBlur={trackCallback('search', { query: query })}
 | 
				
			||||||
          placeholder={'Search'}
 | 
					          placeholder="Search"
 | 
				
			||||||
          className="input input-bordered w-full"
 | 
					          className="w-full"
 | 
				
			||||||
          autoFocus={autoFocus}
 | 
					          autoFocus={autoFocus}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        {!isMobile && !query && (
 | 
					        {!isMobile && !query && (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,16 @@
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { useState } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { CurrencyDollarIcon } from '@heroicons/react/outline'
 | 
					import { CurrencyDollarIcon } from '@heroicons/react/outline'
 | 
				
			||||||
import { Contract } from 'common/contract'
 | 
					import { Contract } from 'common/contract'
 | 
				
			||||||
import { Tooltip } from 'web/components/tooltip'
 | 
					 | 
				
			||||||
import { formatMoney } from 'common/util/format'
 | 
					 | 
				
			||||||
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
 | 
					import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
 | 
				
			||||||
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
 | 
					import { Tooltip } from 'web/components/tooltip'
 | 
				
			||||||
 | 
					import { CommentBountyDialog } from './comment-bounty-dialog'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function BountiedContractBadge() {
 | 
					export function BountiedContractBadge() {
 | 
				
			||||||
  return (
 | 
					  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">
 | 
					    <span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3  py-0.5 text-sm font-medium text-white">
 | 
				
			||||||
      <CurrencyDollarIcon className={'h4 w-4'} /> Bounty
 | 
					      <CurrencyDollarIcon className={'h4 w-4'} /> Bounty
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
| 
						 | 
					@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { contract, showAmount } = props
 | 
					  const { contract, showAmount } = props
 | 
				
			||||||
  const { openCommentBounties } = contract
 | 
					  const { openCommentBounties } = contract
 | 
				
			||||||
  if (!openCommentBounties) return <div />
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  const [open, setOpen] = useState(false)
 | 
				
			||||||
    <Tooltip
 | 
					
 | 
				
			||||||
      text={CommentBountiesTooltipText(
 | 
					  if (!openCommentBounties && !showAmount) return <></>
 | 
				
			||||||
        contract.creatorName,
 | 
					
 | 
				
			||||||
        openCommentBounties
 | 
					  const modal = (
 | 
				
			||||||
      )}
 | 
					    <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
 | 
				
			||||||
      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 = (
 | 
					  const bountiesClosed =
 | 
				
			||||||
  creator: string,
 | 
					    contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
 | 
				
			||||||
  openCommentBounties: number
 | 
					
 | 
				
			||||||
) =>
 | 
					  if (!openCommentBounties) {
 | 
				
			||||||
  `${creator} may award ${formatMoney(
 | 
					    if (bountiesClosed) return <></>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <>
 | 
				
			||||||
 | 
					        {modal}
 | 
				
			||||||
 | 
					        <SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
 | 
				
			||||||
 | 
					      </>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const tooltip = `${contract.creatorName} may award ${formatMoney(
 | 
				
			||||||
    COMMENT_BOUNTY_AMOUNT
 | 
					    COMMENT_BOUNTY_AMOUNT
 | 
				
			||||||
  )} for good comments. ${formatMoney(
 | 
					  )} for good comments. ${formatMoney(
 | 
				
			||||||
    openCommentBounties
 | 
					    openCommentBounties
 | 
				
			||||||
  )} currently available.`
 | 
					  )} currently available.`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Tooltip text={tooltip} placement="bottom">
 | 
				
			||||||
 | 
					      {modal}
 | 
				
			||||||
 | 
					      <SmallBadge
 | 
				
			||||||
 | 
					        text={`${formatMoney(openCommentBounties)} bounty`}
 | 
				
			||||||
 | 
					        onClick={bountiesClosed ? undefined : () => setOpen(true)}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </Tooltip>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function SmallBadge(props: { text: string; onClick?: () => void }) {
 | 
				
			||||||
 | 
					  const { text, onClick } = props
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <button
 | 
				
			||||||
 | 
					      onClick={onClick}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white',
 | 
				
			||||||
 | 
					        !onClick && 'cursor-default'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <CurrencyDollarIcon className={'h4 w-4'} />
 | 
				
			||||||
 | 
					      {text}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,9 +8,16 @@ import clsx from 'clsx'
 | 
				
			||||||
import { formatMoney } from 'common/util/format'
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
 | 
					import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
 | 
				
			||||||
import { Button } from 'web/components/button'
 | 
					import { Button } from 'web/components/button'
 | 
				
			||||||
 | 
					import { Title } from '../title'
 | 
				
			||||||
 | 
					import { Col } from '../layout/col'
 | 
				
			||||||
 | 
					import { Modal } from '../layout/modal'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function AddCommentBountyPanel(props: { contract: Contract }) {
 | 
					export function CommentBountyDialog(props: {
 | 
				
			||||||
  const { contract } = props
 | 
					  contract: Contract
 | 
				
			||||||
 | 
					  open: boolean
 | 
				
			||||||
 | 
					  setOpen: (open: boolean) => void
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { contract, open, setOpen } = props
 | 
				
			||||||
  const { id: contractId, slug } = contract
 | 
					  const { id: contractId, slug } = contract
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const user = useUser()
 | 
					  const user = useUser()
 | 
				
			||||||
| 
						 | 
					@ -45,30 +52,34 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <Modal open={open} setOpen={setOpen}>
 | 
				
			||||||
      <div className="mb-4 text-gray-500">
 | 
					      <Col className="gap-4 rounded bg-white p-6">
 | 
				
			||||||
        Add a {formatMoney(amount)} bounty for good comments that the creator
 | 
					        <Title className="!mt-0 !mb-0" text="Comment bounty" />
 | 
				
			||||||
        can award.{' '}
 | 
					 | 
				
			||||||
        {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Row className={'items-center gap-2'}>
 | 
					        <div className="mb-4 text-gray-500">
 | 
				
			||||||
        <Button
 | 
					          Add a {formatMoney(amount)} bounty for good comments that the creator
 | 
				
			||||||
          className={clsx('ml-2', isLoading && 'btn-disabled')}
 | 
					          can award.{' '}
 | 
				
			||||||
          onClick={submit}
 | 
					          {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
 | 
				
			||||||
          disabled={isLoading}
 | 
					        </div>
 | 
				
			||||||
          color={'blue'}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          Add {formatMoney(amount)} bounty
 | 
					 | 
				
			||||||
        </Button>
 | 
					 | 
				
			||||||
        <span className={'text-error'}>{error}</span>
 | 
					 | 
				
			||||||
      </Row>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {isSuccess && amount && (
 | 
					        <Row className={'items-center gap-2'}>
 | 
				
			||||||
        <div>Success! Added {formatMoney(amount)} in bounties.</div>
 | 
					          <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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {isLoading && <div>Processing...</div>}
 | 
					        {isSuccess && amount && (
 | 
				
			||||||
    </>
 | 
					          <div>Success! Added {formatMoney(amount)} in bounties.</div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {isLoading && <div>Processing...</div>}
 | 
				
			||||||
 | 
					      </Col>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,20 +1,22 @@
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import dayjs from 'dayjs'
 | 
					import dayjs from 'dayjs'
 | 
				
			||||||
import { useState } from 'react'
 | 
					import { useState } from 'react'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
 | 
					import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
 | 
				
			||||||
import { exhibitExts } from 'common/util/parse'
 | 
					 | 
				
			||||||
import { useAdmin } from 'web/hooks/use-admin'
 | 
					import { useAdmin } from 'web/hooks/use-admin'
 | 
				
			||||||
import { useUser } from 'web/hooks/use-user'
 | 
					import { useUser } from 'web/hooks/use-user'
 | 
				
			||||||
import { updateContract } from 'web/lib/firebase/contracts'
 | 
					import { updateContract } from 'web/lib/firebase/contracts'
 | 
				
			||||||
import { Row } from '../layout/row'
 | 
					import { Row } from '../layout/row'
 | 
				
			||||||
import { Content } from '../editor'
 | 
					import { Content } from '../editor'
 | 
				
			||||||
import { TextEditor, useTextEditor } from 'web/components/editor'
 | 
					import {
 | 
				
			||||||
 | 
					  TextEditor,
 | 
				
			||||||
 | 
					  editorExtensions,
 | 
				
			||||||
 | 
					  useTextEditor,
 | 
				
			||||||
 | 
					} from 'web/components/editor'
 | 
				
			||||||
import { Button } from '../button'
 | 
					import { Button } from '../button'
 | 
				
			||||||
import { Spacer } from '../layout/spacer'
 | 
					import { Spacer } from '../layout/spacer'
 | 
				
			||||||
import { Editor, Content as ContentType } from '@tiptap/react'
 | 
					import { Editor, Content as ContentType } from '@tiptap/react'
 | 
				
			||||||
import { insertContent } from '../editor/utils'
 | 
					import { insertContent } from '../editor/utils'
 | 
				
			||||||
 | 
					import { ExpandingInput } from '../expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function ContractDescription(props: {
 | 
					export function ContractDescription(props: {
 | 
				
			||||||
  contract: Contract
 | 
					  contract: Contract
 | 
				
			||||||
| 
						 | 
					@ -120,7 +122,10 @@ function EditQuestion(props: {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function joinContent(oldContent: ContentType, newContent: string) {
 | 
					  function joinContent(oldContent: ContentType, newContent: string) {
 | 
				
			||||||
    const editor = new Editor({ content: oldContent, extensions: exhibitExts })
 | 
					    const editor = new Editor({
 | 
				
			||||||
 | 
					      content: oldContent,
 | 
				
			||||||
 | 
					      extensions: editorExtensions(),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
    editor.commands.focus('end')
 | 
					    editor.commands.focus('end')
 | 
				
			||||||
    insertContent(editor, newContent)
 | 
					    insertContent(editor, newContent)
 | 
				
			||||||
    return editor.getJSON()
 | 
					    return editor.getJSON()
 | 
				
			||||||
| 
						 | 
					@ -139,8 +144,8 @@ function EditQuestion(props: {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return editing ? (
 | 
					  return editing ? (
 | 
				
			||||||
    <div className="mt-4">
 | 
					    <div className="mt-4">
 | 
				
			||||||
      <Textarea
 | 
					      <ExpandingInput
 | 
				
			||||||
        className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
 | 
					        className="mb-1 h-24 w-full"
 | 
				
			||||||
        rows={2}
 | 
					        rows={2}
 | 
				
			||||||
        value={text}
 | 
					        value={text}
 | 
				
			||||||
        onChange={(e) => setText(e.target.value || '')}
 | 
					        onChange={(e) => setText(e.target.value || '')}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,6 @@ import clsx from 'clsx'
 | 
				
			||||||
import { Editor } from '@tiptap/react'
 | 
					import { Editor } from '@tiptap/react'
 | 
				
			||||||
import dayjs from 'dayjs'
 | 
					import dayjs from 'dayjs'
 | 
				
			||||||
import Link from 'next/link'
 | 
					import Link from 'next/link'
 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Row } from '../layout/row'
 | 
					import { Row } from '../layout/row'
 | 
				
			||||||
import { formatMoney } from 'common/util/format'
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
 | 
					import { Contract, updateContract } from 'web/lib/firebase/contracts'
 | 
				
			||||||
| 
						 | 
					@ -20,7 +19,6 @@ import NewContractBadge from '../new-contract-badge'
 | 
				
			||||||
import { MiniUserFollowButton } from '../follow-button'
 | 
					import { MiniUserFollowButton } from '../follow-button'
 | 
				
			||||||
import { DAY_MS } from 'common/util/time'
 | 
					import { DAY_MS } from 'common/util/time'
 | 
				
			||||||
import { useUser, useUserById } from 'web/hooks/use-user'
 | 
					import { useUser, useUserById } from 'web/hooks/use-user'
 | 
				
			||||||
import { exhibitExts } from 'common/util/parse'
 | 
					 | 
				
			||||||
import { Button } from 'web/components/button'
 | 
					import { Button } from 'web/components/button'
 | 
				
			||||||
import { Modal } from 'web/components/layout/modal'
 | 
					import { Modal } from 'web/components/layout/modal'
 | 
				
			||||||
import { Col } from 'web/components/layout/col'
 | 
					import { Col } from 'web/components/layout/col'
 | 
				
			||||||
| 
						 | 
					@ -40,6 +38,8 @@ import {
 | 
				
			||||||
  BountiedContractBadge,
 | 
					  BountiedContractBadge,
 | 
				
			||||||
  BountiedContractSmallBadge,
 | 
					  BountiedContractSmallBadge,
 | 
				
			||||||
} from 'web/components/contract/bountied-contract-badge'
 | 
					} from 'web/components/contract/bountied-contract-badge'
 | 
				
			||||||
 | 
					import { Input } from '../input'
 | 
				
			||||||
 | 
					import { editorExtensions } from '../editor'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ShowTime = 'resolve-date' | 'close-date'
 | 
					export type ShowTime = 'resolve-date' | 'close-date'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -153,8 +153,8 @@ export function MarketSubheader(props: {
 | 
				
			||||||
  const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
 | 
					  const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
 | 
				
			||||||
  const { resolvedDate } = contractMetrics(contract)
 | 
					  const { resolvedDate } = contractMetrics(contract)
 | 
				
			||||||
  const user = useUser()
 | 
					  const user = useUser()
 | 
				
			||||||
  const correctResolutionPercentage =
 | 
					  const creator = useUserById(creatorId)
 | 
				
			||||||
    useUserById(creatorId)?.fractionResolvedCorrectly
 | 
					  const correctResolutionPercentage = creator?.fractionResolvedCorrectly
 | 
				
			||||||
  const isCreator = user?.id === creatorId
 | 
					  const isCreator = user?.id === creatorId
 | 
				
			||||||
  const isMobile = useIsMobile()
 | 
					  const isMobile = useIsMobile()
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -177,12 +177,14 @@ export function MarketSubheader(props: {
 | 
				
			||||||
          {disabled ? (
 | 
					          {disabled ? (
 | 
				
			||||||
            creatorName
 | 
					            creatorName
 | 
				
			||||||
          ) : (
 | 
					          ) : (
 | 
				
			||||||
            <UserLink
 | 
					            <Row className={'gap-2'}>
 | 
				
			||||||
              className="my-auto whitespace-nowrap"
 | 
					              <UserLink
 | 
				
			||||||
              name={creatorName}
 | 
					                className="my-auto whitespace-nowrap"
 | 
				
			||||||
              username={creatorUsername}
 | 
					                name={creatorName}
 | 
				
			||||||
              short={isMobile}
 | 
					                username={creatorUsername}
 | 
				
			||||||
            />
 | 
					              />
 | 
				
			||||||
 | 
					              {/*<BadgeDisplay user={creator} />*/}
 | 
				
			||||||
 | 
					            </Row>
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
          {correctResolutionPercentage != null &&
 | 
					          {correctResolutionPercentage != null &&
 | 
				
			||||||
            correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
 | 
					            correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
 | 
				
			||||||
| 
						 | 
					@ -418,7 +420,7 @@ function EditableCloseDate(props: {
 | 
				
			||||||
      const content = contract.description
 | 
					      const content = contract.description
 | 
				
			||||||
      const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
 | 
					      const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const editor = new Editor({ content, extensions: exhibitExts })
 | 
					      const editor = new Editor({ content, extensions: editorExtensions() })
 | 
				
			||||||
      editor.commands.focus('end')
 | 
					      editor.commands.focus('end')
 | 
				
			||||||
      insertContent(
 | 
					      insertContent(
 | 
				
			||||||
        editor,
 | 
					        editor,
 | 
				
			||||||
| 
						 | 
					@ -445,17 +447,17 @@ function EditableCloseDate(props: {
 | 
				
			||||||
        <Col className="rounded bg-white px-8 pb-8">
 | 
					        <Col className="rounded bg-white px-8 pb-8">
 | 
				
			||||||
          <Subtitle text="Edit market close time" />
 | 
					          <Subtitle text="Edit market close time" />
 | 
				
			||||||
          <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
 | 
					          <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              type="date"
 | 
					              type="date"
 | 
				
			||||||
              className="input input-bordered w-full shrink-0 sm:w-fit"
 | 
					              className="w-full shrink-0 sm:w-fit"
 | 
				
			||||||
              onClick={(e) => e.stopPropagation()}
 | 
					              onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
              onChange={(e) => setCloseDate(e.target.value)}
 | 
					              onChange={(e) => setCloseDate(e.target.value)}
 | 
				
			||||||
              min={Date.now()}
 | 
					              min={Date.now()}
 | 
				
			||||||
              value={closeDate}
 | 
					              value={closeDate}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              type="time"
 | 
					              type="time"
 | 
				
			||||||
              className="input input-bordered w-full shrink-0 sm:w-max"
 | 
					              className="w-full shrink-0 sm:w-max"
 | 
				
			||||||
              onClick={(e) => e.stopPropagation()}
 | 
					              onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
              onChange={(e) => setCloseHoursMinutes(e.target.value)}
 | 
					              onChange={(e) => setCloseHoursMinutes(e.target.value)}
 | 
				
			||||||
              min="00:00"
 | 
					              min="00:00"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,15 +2,17 @@ import { Bet } from 'common/bet'
 | 
				
			||||||
import { resolvedPayout } from 'common/calculate'
 | 
					import { resolvedPayout } from 'common/calculate'
 | 
				
			||||||
import { Contract } from 'common/contract'
 | 
					import { Contract } from 'common/contract'
 | 
				
			||||||
import { formatMoney } from 'common/util/format'
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
 | 
					
 | 
				
			||||||
import { memo } from 'react'
 | 
					import { groupBy, mapValues, sumBy } from 'lodash'
 | 
				
			||||||
import { useComments } from 'web/hooks/use-comments'
 | 
					 | 
				
			||||||
import { FeedBet } from '../feed/feed-bets'
 | 
					import { FeedBet } from '../feed/feed-bets'
 | 
				
			||||||
import { FeedComment } from '../feed/feed-comments'
 | 
					import { FeedComment } from '../feed/feed-comments'
 | 
				
			||||||
import { Spacer } from '../layout/spacer'
 | 
					import { Spacer } from '../layout/spacer'
 | 
				
			||||||
import { Leaderboard } from '../leaderboard'
 | 
					import { Leaderboard } from '../leaderboard'
 | 
				
			||||||
import { Title } from '../title'
 | 
					import { Title } from '../title'
 | 
				
			||||||
import { BETTORS } from 'common/user'
 | 
					import { BETTORS } from 'common/user'
 | 
				
			||||||
 | 
					import { scoreCommentorsAndBettors } from 'common/scoring'
 | 
				
			||||||
 | 
					import { ContractComment } from 'common/comment'
 | 
				
			||||||
 | 
					import { memo } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
 | 
					export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
 | 
				
			||||||
  contract: Contract
 | 
					  contract: Contract
 | 
				
			||||||
| 
						 | 
					@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
 | 
				
			||||||
  ) : null
 | 
					  ) : null
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
 | 
					export function ContractTopTrades(props: {
 | 
				
			||||||
  const { contract, bets } = props
 | 
					  contract: Contract
 | 
				
			||||||
  // todo: this stuff should be calced in DB at resolve time
 | 
					  bets: Bet[]
 | 
				
			||||||
  const comments = useComments(contract.id)
 | 
					  comments: ContractComment[]
 | 
				
			||||||
  const betsById = keyBy(bets, 'id')
 | 
					}) {
 | 
				
			||||||
 | 
					  const { contract, bets, comments } = props
 | 
				
			||||||
  // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
 | 
					  const {
 | 
				
			||||||
  // Otherwise, we record the profit at resolution time
 | 
					    topBetId,
 | 
				
			||||||
  const profitById: Record<string, number> = {}
 | 
					    topBettor,
 | 
				
			||||||
  for (const bet of bets) {
 | 
					    profitById,
 | 
				
			||||||
    if (bet.sale) {
 | 
					    betsById,
 | 
				
			||||||
      const originalBet = betsById[bet.sale.betId]
 | 
					    topCommentId,
 | 
				
			||||||
      const profit = bet.sale.amount - originalBet.amount
 | 
					    commentsById,
 | 
				
			||||||
      profitById[bet.id] = profit
 | 
					    topCommentBetId,
 | 
				
			||||||
      profitById[originalBet.id] = profit
 | 
					  } = scoreCommentorsAndBettors(contract, bets, comments)
 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Now find the betId with the highest profit
 | 
					 | 
				
			||||||
  const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
 | 
					 | 
				
			||||||
  const topBettor = betsById[topBetId]?.userName
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // And also the comment with the highest profit
 | 
					 | 
				
			||||||
  const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="mt-12 max-w-sm">
 | 
					    <div className="mt-12 max-w-sm">
 | 
				
			||||||
      {topComment && profitById[topComment.id] > 0 && (
 | 
					      {topCommentBetId && profitById[topCommentBetId] > 0 && (
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
          <Title text="💬 Proven correct" className="!mt-0" />
 | 
					          <Title text="💬 Proven correct" className="!mt-0" />
 | 
				
			||||||
          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
					          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
				
			||||||
            <FeedComment contract={contract} comment={topComment} />
 | 
					            <FeedComment
 | 
				
			||||||
 | 
					              contract={contract}
 | 
				
			||||||
 | 
					              comment={commentsById[topCommentId]}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <Spacer h={16} />
 | 
					          <Spacer h={16} />
 | 
				
			||||||
        </>
 | 
					        </>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* If they're the same, only show the comment; otherwise show both */}
 | 
					      {/* If they're the same, only show the comment; otherwise show both */}
 | 
				
			||||||
      {topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
 | 
					      {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
          <Title text="💸 Best bet" className="!mt-0" />
 | 
					          <Title text="💸 Best bet" className="!mt-0" />
 | 
				
			||||||
          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
					          <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
 | 
				
			||||||
  const { contract } = props
 | 
					  const { contract } = props
 | 
				
			||||||
  const tips = useTipTxns({ contractId: contract.id })
 | 
					  const tips = useTipTxns({ contractId: contract.id })
 | 
				
			||||||
  const comments = useComments(contract.id) ?? props.comments
 | 
					  const comments = useComments(contract.id) ?? props.comments
 | 
				
			||||||
  const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Best', {
 | 
					  const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
 | 
				
			||||||
    key: `contract-comments-sort`,
 | 
					    key: `contract-comments-sort`,
 | 
				
			||||||
    store: storageStore(safeLocalStorage()),
 | 
					    store: storageStore(safeLocalStorage()),
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
| 
						 | 
					@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
 | 
				
			||||||
        <Col className="mt-8 flex w-full">
 | 
					        <Col className="mt-8 flex w-full">
 | 
				
			||||||
          <div className="text-md mt-8 mb-2 text-left">General Comments</div>
 | 
					          <div className="text-md mt-8 mb-2 text-left">General Comments</div>
 | 
				
			||||||
          <div className="mb-4 w-full border-b border-gray-200" />
 | 
					          <div className="mb-4 w-full border-b border-gray-200" />
 | 
				
			||||||
          {sortRow}
 | 
					 | 
				
			||||||
          <ContractCommentInput className="mb-5" contract={contract} />
 | 
					          <ContractCommentInput className="mb-5" contract={contract} />
 | 
				
			||||||
 | 
					          {sortRow}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          {generalTopLevelComments.map((comment) => (
 | 
					          {generalTopLevelComments.map((comment) => (
 | 
				
			||||||
            <FeedCommentThread
 | 
					            <FeedCommentThread
 | 
				
			||||||
              key={comment.id}
 | 
					              key={comment.id}
 | 
				
			||||||
| 
						 | 
					@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
 | 
				
			||||||
  } else {
 | 
					  } else {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <>
 | 
					      <>
 | 
				
			||||||
        {sortRow}
 | 
					 | 
				
			||||||
        <ContractCommentInput className="mb-5" contract={contract} />
 | 
					        <ContractCommentInput className="mb-5" contract={contract} />
 | 
				
			||||||
 | 
					        {sortRow}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {topLevelComments.map((parent) => (
 | 
					        {topLevelComments.map((parent) => (
 | 
				
			||||||
          <FeedCommentThread
 | 
					          <FeedCommentThread
 | 
				
			||||||
            key={parent.id}
 | 
					            key={parent.id}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,7 +16,8 @@ import { InfoTooltip } from 'web/components/info-tooltip'
 | 
				
			||||||
import { BETTORS, PRESENT_BET } from 'common/user'
 | 
					import { BETTORS, PRESENT_BET } from 'common/user'
 | 
				
			||||||
import { buildArray } from 'common/util/array'
 | 
					import { buildArray } from 'common/util/array'
 | 
				
			||||||
import { useAdmin } from 'web/hooks/use-admin'
 | 
					import { useAdmin } from 'web/hooks/use-admin'
 | 
				
			||||||
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
 | 
					import { AlertBox } from '../alert-box'
 | 
				
			||||||
 | 
					import { Spacer } from '../layout/spacer'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function LiquidityBountyPanel(props: { contract: Contract }) {
 | 
					export function LiquidityBountyPanel(props: { contract: Contract }) {
 | 
				
			||||||
  const { contract } = props
 | 
					  const { contract } = props
 | 
				
			||||||
| 
						 | 
					@ -36,13 +37,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) {
 | 
				
			||||||
  const isCreator = user?.id === contract.creatorId
 | 
					  const isCreator = user?.id === contract.creatorId
 | 
				
			||||||
  const isAdmin = useAdmin()
 | 
					  const isAdmin = useAdmin()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!isCreator && !isAdmin && !showWithdrawal) return <></>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Tabs
 | 
					    <Tabs
 | 
				
			||||||
      tabs={buildArray(
 | 
					      tabs={buildArray(
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          title: 'Bounty Comments',
 | 
					 | 
				
			||||||
          content: <AddCommentBountyPanel contract={contract} />,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        (isCreator || isAdmin) &&
 | 
					        (isCreator || isAdmin) &&
 | 
				
			||||||
          isCPMM && {
 | 
					          isCPMM && {
 | 
				
			||||||
            title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
 | 
					            title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
 | 
				
			||||||
| 
						 | 
					@ -118,7 +117,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
 | 
				
			||||||
      <div className="mb-4 text-gray-500">
 | 
					      <div className="mb-4 text-gray-500">
 | 
				
			||||||
        Contribute your M$ to make this market more accurate.{' '}
 | 
					        Contribute your M$ to make this market more accurate.{' '}
 | 
				
			||||||
        <InfoTooltip
 | 
					        <InfoTooltip
 | 
				
			||||||
          text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
 | 
					          text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}.`}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -145,6 +144,12 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {isLoading && <div>Processing...</div>}
 | 
					      {isLoading && <div>Processing...</div>}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <Spacer h={2} />
 | 
				
			||||||
 | 
					      <AlertBox
 | 
				
			||||||
 | 
					        title="Withdrawals ending"
 | 
				
			||||||
 | 
					        text="Manifold is moving to a new system for handling subsidization. As part of this process, liquidity withdrawals will be disabled shortly. Feel free to withdraw any outstanding liquidity you've added now."
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,6 @@
 | 
				
			||||||
import { useState } from 'react'
 | 
					import { useState } from 'react'
 | 
				
			||||||
import { Spacer } from 'web/components/layout/spacer'
 | 
					import { Spacer } from 'web/components/layout/spacer'
 | 
				
			||||||
import { Title } from 'web/components/title'
 | 
					import { Title } from 'web/components/title'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { TextEditor, useTextEditor } from 'web/components/editor'
 | 
					import { TextEditor, useTextEditor } from 'web/components/editor'
 | 
				
			||||||
import { createPost } from 'web/lib/firebase/api'
 | 
					import { createPost } from 'web/lib/firebase/api'
 | 
				
			||||||
| 
						 | 
					@ -10,6 +9,7 @@ import Router from 'next/router'
 | 
				
			||||||
import { MAX_POST_TITLE_LENGTH } from 'common/post'
 | 
					import { MAX_POST_TITLE_LENGTH } from 'common/post'
 | 
				
			||||||
import { postPath } from 'web/lib/firebase/posts'
 | 
					import { postPath } from 'web/lib/firebase/posts'
 | 
				
			||||||
import { Group } from 'common/group'
 | 
					import { Group } from 'common/group'
 | 
				
			||||||
 | 
					import { ExpandingInput } from './expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function CreatePost(props: { group?: Group }) {
 | 
					export function CreatePost(props: { group?: Group }) {
 | 
				
			||||||
  const [title, setTitle] = useState('')
 | 
					  const [title, setTitle] = useState('')
 | 
				
			||||||
| 
						 | 
					@ -62,9 +62,8 @@ export function CreatePost(props: { group?: Group }) {
 | 
				
			||||||
                Title<span className={'text-red-700'}> *</span>
 | 
					                Title<span className={'text-red-700'}> *</span>
 | 
				
			||||||
              </span>
 | 
					              </span>
 | 
				
			||||||
            </label>
 | 
					            </label>
 | 
				
			||||||
            <Textarea
 | 
					            <ExpandingInput
 | 
				
			||||||
              placeholder="e.g. Elon Mania Post"
 | 
					              placeholder="e.g. Elon Mania Post"
 | 
				
			||||||
              className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
              autoFocus
 | 
					              autoFocus
 | 
				
			||||||
              maxLength={MAX_POST_TITLE_LENGTH}
 | 
					              maxLength={MAX_POST_TITLE_LENGTH}
 | 
				
			||||||
              value={title}
 | 
					              value={title}
 | 
				
			||||||
| 
						 | 
					@ -76,9 +75,8 @@ export function CreatePost(props: { group?: Group }) {
 | 
				
			||||||
                Subtitle<span className={'text-red-700'}> *</span>
 | 
					                Subtitle<span className={'text-red-700'}> *</span>
 | 
				
			||||||
              </span>
 | 
					              </span>
 | 
				
			||||||
            </label>
 | 
					            </label>
 | 
				
			||||||
            <Textarea
 | 
					            <ExpandingInput
 | 
				
			||||||
              placeholder="e.g. How Elon Musk is getting everyone's attention"
 | 
					              placeholder="e.g. How Elon Musk is getting everyone's attention"
 | 
				
			||||||
              className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
              autoFocus
 | 
					              autoFocus
 | 
				
			||||||
              maxLength={MAX_POST_TITLE_LENGTH}
 | 
					              maxLength={MAX_POST_TITLE_LENGTH}
 | 
				
			||||||
              value={subtitle}
 | 
					              value={subtitle}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import {
 | 
				
			||||||
  Content,
 | 
					  Content,
 | 
				
			||||||
  Editor,
 | 
					  Editor,
 | 
				
			||||||
  mergeAttributes,
 | 
					  mergeAttributes,
 | 
				
			||||||
 | 
					  Extensions,
 | 
				
			||||||
} from '@tiptap/react'
 | 
					} from '@tiptap/react'
 | 
				
			||||||
import StarterKit from '@tiptap/starter-kit'
 | 
					import StarterKit from '@tiptap/starter-kit'
 | 
				
			||||||
import { Image } from '@tiptap/extension-image'
 | 
					import { Image } from '@tiptap/extension-image'
 | 
				
			||||||
| 
						 | 
					@ -19,9 +20,7 @@ import { uploadImage } from 'web/lib/firebase/storage'
 | 
				
			||||||
import { useMutation } from 'react-query'
 | 
					import { useMutation } from 'react-query'
 | 
				
			||||||
import { FileUploadButton } from './file-upload-button'
 | 
					import { FileUploadButton } from './file-upload-button'
 | 
				
			||||||
import { linkClass } from './site-link'
 | 
					import { linkClass } from './site-link'
 | 
				
			||||||
import { mentionSuggestion } from './editor/mention-suggestion'
 | 
					 | 
				
			||||||
import { DisplayMention } from './editor/mention'
 | 
					import { DisplayMention } from './editor/mention'
 | 
				
			||||||
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
 | 
					 | 
				
			||||||
import { DisplayContractMention } from './editor/contract-mention'
 | 
					import { DisplayContractMention } from './editor/contract-mention'
 | 
				
			||||||
import Iframe from 'common/util/tiptap-iframe'
 | 
					import Iframe from 'common/util/tiptap-iframe'
 | 
				
			||||||
import TiptapTweet from './editor/tiptap-tweet'
 | 
					import TiptapTweet from './editor/tiptap-tweet'
 | 
				
			||||||
| 
						 | 
					@ -70,6 +69,22 @@ const DisplayLink = Link.extend({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const editorExtensions = (simple = false): Extensions => [
 | 
				
			||||||
 | 
					  StarterKit.configure({
 | 
				
			||||||
 | 
					    heading: simple ? false : { levels: [1, 2, 3] },
 | 
				
			||||||
 | 
					    horizontalRule: simple ? false : {},
 | 
				
			||||||
 | 
					  }),
 | 
				
			||||||
 | 
					  simple ? DisplayImage : Image,
 | 
				
			||||||
 | 
					  DisplayLink,
 | 
				
			||||||
 | 
					  DisplayMention,
 | 
				
			||||||
 | 
					  DisplayContractMention,
 | 
				
			||||||
 | 
					  Iframe,
 | 
				
			||||||
 | 
					  TiptapTweet,
 | 
				
			||||||
 | 
					  TiptapSpoiler.configure({
 | 
				
			||||||
 | 
					    spoilerOpenClass: 'rounded-sm bg-greyscale-2',
 | 
				
			||||||
 | 
					  }),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const proseClass = clsx(
 | 
					const proseClass = clsx(
 | 
				
			||||||
  'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
 | 
					  'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
 | 
				
			||||||
  'font-light prose-a:font-light prose-blockquote:font-light'
 | 
					  'font-light prose-a:font-light prose-blockquote:font-light'
 | 
				
			||||||
| 
						 | 
					@ -110,29 +125,13 @@ export function useTextEditor(props: {
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
 | 
					    onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
 | 
				
			||||||
    extensions: [
 | 
					    extensions: [
 | 
				
			||||||
      StarterKit.configure({
 | 
					      ...editorExtensions(simple),
 | 
				
			||||||
        heading: simple ? false : { levels: [1, 2, 3] },
 | 
					 | 
				
			||||||
        horizontalRule: simple ? false : {},
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      Placeholder.configure({
 | 
					      Placeholder.configure({
 | 
				
			||||||
        placeholder,
 | 
					        placeholder,
 | 
				
			||||||
        emptyEditorClass:
 | 
					        emptyEditorClass:
 | 
				
			||||||
          'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
 | 
					          'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
 | 
				
			||||||
      }),
 | 
					      }),
 | 
				
			||||||
      CharacterCount.configure({ limit: max }),
 | 
					      CharacterCount.configure({ limit: max }),
 | 
				
			||||||
      simple ? DisplayImage : Image,
 | 
					 | 
				
			||||||
      DisplayLink,
 | 
					 | 
				
			||||||
      DisplayMention.configure({
 | 
					 | 
				
			||||||
        suggestion: mentionSuggestion,
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      DisplayContractMention.configure({
 | 
					 | 
				
			||||||
        suggestion: contractMentionSuggestion,
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      Iframe,
 | 
					 | 
				
			||||||
      TiptapTweet,
 | 
					 | 
				
			||||||
      TiptapSpoiler.configure({
 | 
					 | 
				
			||||||
        spoilerOpenClass: 'rounded-sm bg-greyscale-2',
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    content: defaultValue ?? (key && content ? content : ''),
 | 
					    content: defaultValue ?? (key && content ? content : ''),
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
| 
						 | 
					@ -355,10 +354,7 @@ export function RichContent(props: {
 | 
				
			||||||
      smallImage ? DisplayImage : Image,
 | 
					      smallImage ? DisplayImage : Image,
 | 
				
			||||||
      DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
 | 
					      DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
 | 
				
			||||||
      DisplayMention,
 | 
					      DisplayMention,
 | 
				
			||||||
      DisplayContractMention.configure({
 | 
					      DisplayContractMention,
 | 
				
			||||||
        // Needed to set a different PluginKey for Prosemirror
 | 
					 | 
				
			||||||
        suggestion: contractMentionSuggestion,
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      Iframe,
 | 
					      Iframe,
 | 
				
			||||||
      TiptapTweet,
 | 
					      TiptapTweet,
 | 
				
			||||||
      TiptapSpoiler.configure({
 | 
					      TiptapSpoiler.configure({
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,16 +6,29 @@ import {
 | 
				
			||||||
} from '@tiptap/react'
 | 
					} from '@tiptap/react'
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import { useContract } from 'web/hooks/use-contract'
 | 
					import { useContract } from 'web/hooks/use-contract'
 | 
				
			||||||
import { ContractMention } from '../contract/contract-mention'
 | 
					import { ContractMention } from 'web/components/contract/contract-mention'
 | 
				
			||||||
 | 
					import Link from 'next/link'
 | 
				
			||||||
 | 
					import { contractMentionSuggestion } from './contract-mention-suggestion'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const name = 'contract-mention-component'
 | 
					const name = 'contract-mention-component'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ContractMentionComponent = (props: any) => {
 | 
					const ContractMentionComponent = (props: any) => {
 | 
				
			||||||
  const contract = useContract(props.node.attrs.id)
 | 
					  const { label, id } = props.node.attrs
 | 
				
			||||||
 | 
					  const contract = useContract(id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <NodeViewWrapper className={clsx(name, 'not-prose inline')}>
 | 
					    <NodeViewWrapper className={clsx(name, 'not-prose inline')}>
 | 
				
			||||||
      {contract && <ContractMention contract={contract} />}
 | 
					      {contract ? (
 | 
				
			||||||
 | 
					        <ContractMention contract={contract} />
 | 
				
			||||||
 | 
					      ) : label ? (
 | 
				
			||||||
 | 
					        <Link href={label}>
 | 
				
			||||||
 | 
					          <a className="rounded-sm !text-indigo-700 hover:bg-indigo-50">
 | 
				
			||||||
 | 
					            {label}
 | 
				
			||||||
 | 
					          </a>
 | 
				
			||||||
 | 
					        </Link>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        '[loading...]'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </NodeViewWrapper>
 | 
					    </NodeViewWrapper>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -29,8 +42,5 @@ export const DisplayContractMention = Mention.extend({
 | 
				
			||||||
  name: 'contract-mention',
 | 
					  name: 'contract-mention',
 | 
				
			||||||
  parseHTML: () => [{ tag: name }],
 | 
					  parseHTML: () => [{ tag: name }],
 | 
				
			||||||
  renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
 | 
					  renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
 | 
				
			||||||
  addNodeView: () =>
 | 
					  addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent),
 | 
				
			||||||
    ReactNodeViewRenderer(ContractMentionComponent, {
 | 
					}).configure({ suggestion: contractMentionSuggestion })
 | 
				
			||||||
      // On desktop, render cards below half-width so you can stack two
 | 
					 | 
				
			||||||
    }),
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ import {
 | 
				
			||||||
} from '@tiptap/react'
 | 
					} from '@tiptap/react'
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import { Linkify } from '../linkify'
 | 
					import { Linkify } from '../linkify'
 | 
				
			||||||
 | 
					import { mentionSuggestion } from './mention-suggestion'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const name = 'mention-component'
 | 
					const name = 'mention-component'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,4 +28,4 @@ export const DisplayMention = Mention.extend({
 | 
				
			||||||
  renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
 | 
					  renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
 | 
				
			||||||
  addNodeView: () =>
 | 
					  addNodeView: () =>
 | 
				
			||||||
    ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
 | 
					    ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
 | 
				
			||||||
})
 | 
					}).configure({ suggestion: mentionSuggestion })
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										16
									
								
								web/components/expanding-input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/components/expanding-input.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import Textarea from 'react-expanding-textarea'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/** Expanding `<textarea>` with same style as input.tsx */
 | 
				
			||||||
 | 
					export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => {
 | 
				
			||||||
 | 
					  const { className, ...rest } = props
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Textarea
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        'textarea textarea-bordered resize-none text-[16px] md:text-[14px]',
 | 
				
			||||||
 | 
					        className
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {...rest}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  const totalAwarded = bountiesAwarded ?? 0
 | 
					  const totalAwarded = bountiesAwarded ?? 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const router = useRouter()
 | 
					  const { isReady, asPath } = useRouter()
 | 
				
			||||||
  const highlighted = router.asPath.endsWith(`#${comment.id}`)
 | 
					  const [highlighted, setHighlighted] = useState(false)
 | 
				
			||||||
  const commentRef = useRef<HTMLDivElement>(null)
 | 
					  const commentRef = useRef<HTMLDivElement>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (highlighted && commentRef.current != null) {
 | 
					    if (isReady && asPath.endsWith(`#${comment.id}`)) {
 | 
				
			||||||
 | 
					      setHighlighted(true)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [isReady, asPath, comment.id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (highlighted && commentRef.current) {
 | 
				
			||||||
      commentRef.current.scrollIntoView(true)
 | 
					      commentRef.current.scrollIntoView(true)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [highlighted])
 | 
					  }, [highlighted])
 | 
				
			||||||
| 
						 | 
					@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: {
 | 
				
			||||||
      className={clsx(
 | 
					      className={clsx(
 | 
				
			||||||
        'relative',
 | 
					        'relative',
 | 
				
			||||||
        indent ? 'ml-6' : '',
 | 
					        indent ? 'ml-6' : '',
 | 
				
			||||||
        highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
 | 
					        highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      {/*draw a gray line from the comment to the left:*/}
 | 
					      {/*draw a gray line from the comment to the left:*/}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
 | 
				
			||||||
import { Row } from 'web/components/layout/row'
 | 
					import { Row } from 'web/components/layout/row'
 | 
				
			||||||
import { searchInAny } from 'common/util/parse'
 | 
					import { searchInAny } from 'common/util/parse'
 | 
				
			||||||
import { UserLink } from 'web/components/user-link'
 | 
					import { UserLink } from 'web/components/user-link'
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function FilterSelectUsers(props: {
 | 
					export function FilterSelectUsers(props: {
 | 
				
			||||||
  setSelectedUsers: (users: User[]) => void
 | 
					  setSelectedUsers: (users: User[]) => void
 | 
				
			||||||
| 
						 | 
					@ -50,13 +51,13 @@ export function FilterSelectUsers(props: {
 | 
				
			||||||
            <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
 | 
					            <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
 | 
				
			||||||
              <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
 | 
					              <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              type="text"
 | 
					              type="text"
 | 
				
			||||||
              name="user name"
 | 
					              name="user name"
 | 
				
			||||||
              id="user name"
 | 
					              id="user name"
 | 
				
			||||||
              value={query}
 | 
					              value={query}
 | 
				
			||||||
              onChange={(e) => setQuery(e.target.value)}
 | 
					              onChange={(e) => setQuery(e.target.value)}
 | 
				
			||||||
              className="input input-bordered block w-full pl-10 focus:border-gray-300 "
 | 
					              className="block w-full pl-10"
 | 
				
			||||||
              placeholder="Austin Chen"
 | 
					              placeholder="Austin Chen"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import { Title } from '../title'
 | 
				
			||||||
import { User } from 'common/user'
 | 
					import { User } from 'common/user'
 | 
				
			||||||
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
 | 
					import { MAX_GROUP_NAME_LENGTH } from 'common/group'
 | 
				
			||||||
import { createGroup } from 'web/lib/firebase/api'
 | 
					import { createGroup } from 'web/lib/firebase/api'
 | 
				
			||||||
 | 
					import { Input } from '../input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function CreateGroupButton(props: {
 | 
					export function CreateGroupButton(props: {
 | 
				
			||||||
  user: User
 | 
					  user: User
 | 
				
			||||||
| 
						 | 
					@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="form-control w-full">
 | 
					      <div className="form-control w-full">
 | 
				
			||||||
        <label className="mb-2 ml-1 mt-0">Group name</label>
 | 
					        <label className="mb-2 ml-1 mt-0">Group name</label>
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          placeholder={'Your group name'}
 | 
					          placeholder={'Your group name'}
 | 
				
			||||||
          className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
          disabled={isSubmitting}
 | 
					          disabled={isSubmitting}
 | 
				
			||||||
          value={name}
 | 
					          value={name}
 | 
				
			||||||
          maxLength={MAX_GROUP_NAME_LENGTH}
 | 
					          maxLength={MAX_GROUP_NAME_LENGTH}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
 | 
				
			||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
 | 
					import { FilterSelectUsers } from 'web/components/filter-select-users'
 | 
				
			||||||
import { User } from 'common/user'
 | 
					import { User } from 'common/user'
 | 
				
			||||||
import { useMemberIds } from 'web/hooks/use-group'
 | 
					import { useMemberIds } from 'web/hooks/use-group'
 | 
				
			||||||
 | 
					import { Input } from '../input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function EditGroupButton(props: { group: Group; className?: string }) {
 | 
					export function EditGroupButton(props: { group: Group; className?: string }) {
 | 
				
			||||||
  const { group, className } = props
 | 
					  const { group, className } = props
 | 
				
			||||||
| 
						 | 
					@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
 | 
				
			||||||
              <span className="mb-1">Group name</span>
 | 
					              <span className="mb-1">Group name</span>
 | 
				
			||||||
            </label>
 | 
					            </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              placeholder="Your group name"
 | 
					              placeholder="Your group name"
 | 
				
			||||||
              className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
              disabled={isSubmitting}
 | 
					              disabled={isSubmitting}
 | 
				
			||||||
              value={name}
 | 
					              value={name}
 | 
				
			||||||
              onChange={(e) => setName(e.target.value || '')}
 | 
					              onChange={(e) => setName(e.target.value || '')}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										22
									
								
								web/components/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								web/components/input.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,22 @@
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/** Text input. Wraps html `<input>` */
 | 
				
			||||||
 | 
					export const Input = (props: JSX.IntrinsicElements['input']) => {
 | 
				
			||||||
 | 
					  const { className, ...rest } = props
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <input
 | 
				
			||||||
 | 
					      className={clsx('input input-bordered text-base md:text-sm', className)}
 | 
				
			||||||
 | 
					      {...rest}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					  TODO: replace daisyui style with our own. For reference:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  james: text-lg placeholder:text-gray-400
 | 
				
			||||||
 | 
					  inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md
 | 
				
			||||||
 | 
					  austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
| 
						 | 
					@ -7,12 +7,13 @@ import { User } from 'common/user'
 | 
				
			||||||
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
 | 
					import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
 | 
				
			||||||
import { createManalink } from 'web/lib/firebase/manalinks'
 | 
					import { createManalink } from 'web/lib/firebase/manalinks'
 | 
				
			||||||
import { Modal } from 'web/components/layout/modal'
 | 
					import { Modal } from 'web/components/layout/modal'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import dayjs from 'dayjs'
 | 
					import dayjs from 'dayjs'
 | 
				
			||||||
import { Button } from '../button'
 | 
					import { Button } from '../button'
 | 
				
			||||||
import { getManalinkUrl } from 'web/pages/links'
 | 
					import { getManalinkUrl } from 'web/pages/links'
 | 
				
			||||||
import { DuplicateIcon } from '@heroicons/react/outline'
 | 
					import { DuplicateIcon } from '@heroicons/react/outline'
 | 
				
			||||||
import { QRCode } from '../qr-code'
 | 
					import { QRCode } from '../qr-code'
 | 
				
			||||||
 | 
					import { Input } from '../input'
 | 
				
			||||||
 | 
					import { ExpandingInput } from '../expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function CreateLinksButton(props: {
 | 
					export function CreateLinksButton(props: {
 | 
				
			||||||
  user: User
 | 
					  user: User
 | 
				
			||||||
| 
						 | 
					@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
 | 
				
			||||||
                <span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
 | 
					                <span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
 | 
				
			||||||
                  M$
 | 
					                  M$
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
                <input
 | 
					                <Input
 | 
				
			||||||
                  className="input input-bordered w-full pl-10"
 | 
					                  className="w-full pl-10"
 | 
				
			||||||
                  type="number"
 | 
					                  type="number"
 | 
				
			||||||
                  min="1"
 | 
					                  min="1"
 | 
				
			||||||
                  value={newManalink.amount}
 | 
					                  value={newManalink.amount}
 | 
				
			||||||
| 
						 | 
					@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
 | 
				
			||||||
            <div className="flex flex-col gap-2 md:flex-row">
 | 
					            <div className="flex flex-col gap-2 md:flex-row">
 | 
				
			||||||
              <div className="form-control w-full md:w-1/2">
 | 
					              <div className="form-control w-full md:w-1/2">
 | 
				
			||||||
                <label className="label">Uses</label>
 | 
					                <label className="label">Uses</label>
 | 
				
			||||||
                <input
 | 
					                <Input
 | 
				
			||||||
                  className="input input-bordered"
 | 
					 | 
				
			||||||
                  type="number"
 | 
					                  type="number"
 | 
				
			||||||
                  min="1"
 | 
					                  min="1"
 | 
				
			||||||
                  value={newManalink.maxUses ?? ''}
 | 
					                  value={newManalink.maxUses ?? ''}
 | 
				
			||||||
| 
						 | 
					@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
 | 
				
			||||||
                      return { ...m, maxUses: parseInt(e.target.value) }
 | 
					                      return { ...m, maxUses: parseInt(e.target.value) }
 | 
				
			||||||
                    })
 | 
					                    })
 | 
				
			||||||
                  }
 | 
					                  }
 | 
				
			||||||
                ></input>
 | 
					                />
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
              <div className="form-control w-full md:w-1/2">
 | 
					              <div className="form-control w-full md:w-1/2">
 | 
				
			||||||
                <label className="label">Expires in</label>
 | 
					                <label className="label">Expires in</label>
 | 
				
			||||||
| 
						 | 
					@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <div className="form-control w-full">
 | 
					            <div className="form-control w-full">
 | 
				
			||||||
              <label className="label">Message</label>
 | 
					              <label className="label">Message</label>
 | 
				
			||||||
              <Textarea
 | 
					              <ExpandingInput
 | 
				
			||||||
                placeholder={defaultMessage}
 | 
					                placeholder={defaultMessage}
 | 
				
			||||||
                maxLength={200}
 | 
					                maxLength={200}
 | 
				
			||||||
                className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
                autoFocus
 | 
					                autoFocus
 | 
				
			||||||
                value={newManalink.message}
 | 
					                value={newManalink.message}
 | 
				
			||||||
                rows="3"
 | 
					                rows="3"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
 | 
				
			||||||
  return buildArray(
 | 
					  return buildArray(
 | 
				
			||||||
    { name: 'Leaderboards', href: '/leaderboards' },
 | 
					    { name: 'Leaderboards', href: '/leaderboards' },
 | 
				
			||||||
    { name: 'Groups', href: '/groups' },
 | 
					    { name: 'Groups', href: '/groups' },
 | 
				
			||||||
    { name: 'Referrals', href: '/referrals' },
 | 
					    { name: 'Refer a friend', href: '/referrals' },
 | 
				
			||||||
    { name: 'Charity', href: '/charity' },
 | 
					    { name: 'Charity', href: '/charity' },
 | 
				
			||||||
    { name: 'Labs', href: '/labs' },
 | 
					    { name: 'Labs', href: '/labs' },
 | 
				
			||||||
    { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
 | 
					    { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
 | 
				
			||||||
| 
						 | 
					@ -215,7 +215,7 @@ function getMoreMobileNav() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return buildArray<MenuItem>(
 | 
					  return buildArray<MenuItem>(
 | 
				
			||||||
    { name: 'Groups', href: '/groups' },
 | 
					    { name: 'Groups', href: '/groups' },
 | 
				
			||||||
    { name: 'Referrals', href: '/referrals' },
 | 
					    { name: 'Refer a friend', href: '/referrals' },
 | 
				
			||||||
    { name: 'Charity', href: '/charity' },
 | 
					    { name: 'Charity', href: '/charity' },
 | 
				
			||||||
    { name: 'Labs', href: '/labs' },
 | 
					    { name: 'Labs', href: '/labs' },
 | 
				
			||||||
    { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
 | 
					    { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -139,6 +139,7 @@ export function NotificationSettings(props: {
 | 
				
			||||||
      'loan_income',
 | 
					      'loan_income',
 | 
				
			||||||
      'limit_order_fills',
 | 
					      'limit_order_fills',
 | 
				
			||||||
      'tips_on_your_comments',
 | 
					      'tips_on_your_comments',
 | 
				
			||||||
 | 
					      'badges_awarded',
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  const userInteractions: SectionData = {
 | 
					  const userInteractions: SectionData = {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ import { ReactNode } from 'react'
 | 
				
			||||||
import React from 'react'
 | 
					import React from 'react'
 | 
				
			||||||
import { Col } from './layout/col'
 | 
					import { Col } from './layout/col'
 | 
				
			||||||
import { Spacer } from './layout/spacer'
 | 
					import { Spacer } from './layout/spacer'
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function NumberInput(props: {
 | 
					export function NumberInput(props: {
 | 
				
			||||||
  numberString: string
 | 
					  numberString: string
 | 
				
			||||||
| 
						 | 
					@ -32,9 +33,9 @@ export function NumberInput(props: {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Col className={className}>
 | 
					    <Col className={className}>
 | 
				
			||||||
      <label className="input-group">
 | 
					      <label className="input-group">
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          className={clsx(
 | 
					          className={clsx(
 | 
				
			||||||
            'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
 | 
					            'max-w-[200px] !text-lg',
 | 
				
			||||||
            error && 'input-error',
 | 
					            error && 'input-error',
 | 
				
			||||||
            inputClassName
 | 
					            inputClassName
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@ import clsx from 'clsx'
 | 
				
			||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
 | 
					import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
 | 
				
			||||||
import { getPseudoProbability } from 'common/pseudo-numeric'
 | 
					import { getPseudoProbability } from 'common/pseudo-numeric'
 | 
				
			||||||
import { BucketInput } from './bucket-input'
 | 
					import { BucketInput } from './bucket-input'
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
import { Col } from './layout/col'
 | 
					import { Col } from './layout/col'
 | 
				
			||||||
import { Spacer } from './layout/spacer'
 | 
					import { Spacer } from './layout/spacer'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Col className={className}>
 | 
					    <Col className={className}>
 | 
				
			||||||
      <label className="input-group">
 | 
					      <label className="input-group">
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          className={clsx(
 | 
					          className={clsx('max-w-[200px] !text-lg', inputClassName)}
 | 
				
			||||||
            'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
 | 
					 | 
				
			||||||
            inputClassName
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          type="number"
 | 
					          type="number"
 | 
				
			||||||
          max={99}
 | 
					          max={99}
 | 
				
			||||||
          min={1}
 | 
					          min={1}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,4 @@
 | 
				
			||||||
 | 
					import { Input } from './input'
 | 
				
			||||||
import { Row } from './layout/row'
 | 
					import { Row } from './layout/row'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function ProbabilitySelector(props: {
 | 
					export function ProbabilitySelector(props: {
 | 
				
			||||||
| 
						 | 
					@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Row className="items-center  gap-2">
 | 
					    <Row className="items-center  gap-2">
 | 
				
			||||||
      <label className="input-group input-group-lg text-lg">
 | 
					      <label className="input-group input-group-lg text-lg">
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          type="number"
 | 
					          type="number"
 | 
				
			||||||
          value={probabilityInt}
 | 
					          value={probabilityInt}
 | 
				
			||||||
          className="input input-bordered input-md w-28 text-lg"
 | 
					          className="input-md w-28 !text-lg"
 | 
				
			||||||
          disabled={isSubmitting}
 | 
					          disabled={isSubmitting}
 | 
				
			||||||
          min={1}
 | 
					          min={1}
 | 
				
			||||||
          max={99}
 | 
					          max={99}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										223
									
								
								web/components/profile/badges-modal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								web/components/profile/badges-modal.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,223 @@
 | 
				
			||||||
 | 
					import { Modal } from 'web/components/layout/modal'
 | 
				
			||||||
 | 
					import { Col } from 'web/components/layout/col'
 | 
				
			||||||
 | 
					import { PAST_BETS, User } from 'common/user'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Badge,
 | 
				
			||||||
 | 
					  calculateBadgeRarity,
 | 
				
			||||||
 | 
					  MarketCreatorBadge,
 | 
				
			||||||
 | 
					  ProvenCorrectBadge,
 | 
				
			||||||
 | 
					  rarities,
 | 
				
			||||||
 | 
					  StreakerBadge,
 | 
				
			||||||
 | 
					} from 'common/badge'
 | 
				
			||||||
 | 
					import { groupBy } from 'lodash'
 | 
				
			||||||
 | 
					import { Row } from 'web/components/layout/row'
 | 
				
			||||||
 | 
					import { SiteLink } from 'web/components/site-link'
 | 
				
			||||||
 | 
					import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
 | 
				
			||||||
 | 
					import { Tooltip } from 'web/components/tooltip'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  bronzeClassName,
 | 
				
			||||||
 | 
					  goldClassName,
 | 
				
			||||||
 | 
					  silverClassName,
 | 
				
			||||||
 | 
					} from 'web/components/badge-display'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function BadgesModal(props: {
 | 
				
			||||||
 | 
					  isOpen: boolean
 | 
				
			||||||
 | 
					  setOpen: (open: boolean) => void
 | 
				
			||||||
 | 
					  user: User
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { isOpen, setOpen, user } = props
 | 
				
			||||||
 | 
					  const { provenCorrect, marketCreator, streaker } = user.achievements ?? {}
 | 
				
			||||||
 | 
					  const badges = [
 | 
				
			||||||
 | 
					    ...(provenCorrect?.badges ?? []),
 | 
				
			||||||
 | 
					    ...(streaker?.badges ?? []),
 | 
				
			||||||
 | 
					    ...(marketCreator?.badges ?? []),
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // group badges by their rarities
 | 
				
			||||||
 | 
					  const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Modal open={isOpen} setOpen={setOpen}>
 | 
				
			||||||
 | 
					      <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
 | 
				
			||||||
 | 
					        <span className={clsx('text-8xl')}>🏅</span>
 | 
				
			||||||
 | 
					        <span className="text-xl">{user.name + "'s"} badges</span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Row className={'flex-wrap gap-2'}>
 | 
				
			||||||
 | 
					          <Col
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              'min-w-full gap-2 rounded-md border-2 border-amber-900 border-opacity-40 p-2 text-center'
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span className={clsx(' ', bronzeClassName)}>Bronze</span>
 | 
				
			||||||
 | 
					            <Row className={'flex-wrap justify-center gap-4'}>
 | 
				
			||||||
 | 
					              {badgesByRarity['bronze'] ? (
 | 
				
			||||||
 | 
					                badgesByRarity['bronze'].map((badge, i) => (
 | 
				
			||||||
 | 
					                  <BadgeToItem badge={badge} key={i} rarity={'bronze'} />
 | 
				
			||||||
 | 
					                ))
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <span className={'text-gray-500'}>None yet</span>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
 | 
					            </Row>
 | 
				
			||||||
 | 
					          </Col>
 | 
				
			||||||
 | 
					          <Col
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              'min-w-full gap-2 rounded-md border-2 border-gray-500 border-opacity-40 p-2 text-center '
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span className={clsx(' ', silverClassName)}>Silver</span>
 | 
				
			||||||
 | 
					            <Row className={'flex-wrap justify-center gap-4'}>
 | 
				
			||||||
 | 
					              {badgesByRarity['silver'] ? (
 | 
				
			||||||
 | 
					                badgesByRarity['silver'].map((badge, i) => (
 | 
				
			||||||
 | 
					                  <BadgeToItem badge={badge} key={i} rarity={'silver'} />
 | 
				
			||||||
 | 
					                ))
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <span className={'text-gray-500'}>None yet</span>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
 | 
					            </Row>
 | 
				
			||||||
 | 
					          </Col>
 | 
				
			||||||
 | 
					          <Col
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              'min-w-full gap-2 rounded-md border-2 border-amber-400  p-2 text-center '
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span className={clsx('', goldClassName)}>Gold</span>
 | 
				
			||||||
 | 
					            <Row className={'flex-wrap justify-center gap-4'}>
 | 
				
			||||||
 | 
					              {badgesByRarity['gold'] ? (
 | 
				
			||||||
 | 
					                badgesByRarity['gold'].map((badge, i) => (
 | 
				
			||||||
 | 
					                  <BadgeToItem badge={badge} key={i} rarity={'gold'} />
 | 
				
			||||||
 | 
					                ))
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <span className={'text-gray-500'}>None yet</span>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
 | 
					            </Row>
 | 
				
			||||||
 | 
					          </Col>
 | 
				
			||||||
 | 
					        </Row>
 | 
				
			||||||
 | 
					      </Col>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function BadgeToItem(props: { badge: Badge; rarity: rarities }) {
 | 
				
			||||||
 | 
					  const { badge, rarity } = props
 | 
				
			||||||
 | 
					  if (badge.type === 'PROVEN_CORRECT')
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <ProvenCorrectBadgeItem
 | 
				
			||||||
 | 
					        badge={badge as ProvenCorrectBadge}
 | 
				
			||||||
 | 
					        rarity={rarity}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  else if (badge.type === 'STREAKER')
 | 
				
			||||||
 | 
					    return <StreakerBadgeItem badge={badge as StreakerBadge} rarity={rarity} />
 | 
				
			||||||
 | 
					  else if (badge.type === 'MARKET_CREATOR')
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <MarketCreatorBadgeItem
 | 
				
			||||||
 | 
					        badge={badge as MarketCreatorBadge}
 | 
				
			||||||
 | 
					        rarity={rarity}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  else return null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function ProvenCorrectBadgeItem(props: {
 | 
				
			||||||
 | 
					  badge: ProvenCorrectBadge
 | 
				
			||||||
 | 
					  rarity: rarities
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { badge, rarity } = props
 | 
				
			||||||
 | 
					  const { betAmount, contractSlug, contractCreatorUsername } = badge.data
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <SiteLink
 | 
				
			||||||
 | 
					      href={contractPathWithoutContract(contractCreatorUsername, contractSlug)}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Col className={'text-center'}>
 | 
				
			||||||
 | 
					        <Medal rarity={rarity} />
 | 
				
			||||||
 | 
					        <Tooltip
 | 
				
			||||||
 | 
					          text={`Make a comment attached to a winning bet worth ${betAmount}`}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <span
 | 
				
			||||||
 | 
					            className={
 | 
				
			||||||
 | 
					              rarity === 'gold'
 | 
				
			||||||
 | 
					                ? goldClassName
 | 
				
			||||||
 | 
					                : rarity === 'silver'
 | 
				
			||||||
 | 
					                ? silverClassName
 | 
				
			||||||
 | 
					                : bronzeClassName
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Proven Correct
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        </Tooltip>
 | 
				
			||||||
 | 
					      </Col>
 | 
				
			||||||
 | 
					    </SiteLink>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) {
 | 
				
			||||||
 | 
					  const { badge, rarity } = props
 | 
				
			||||||
 | 
					  const { totalBettingStreak } = badge.data
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Col className={'cursor-default text-center'}>
 | 
				
			||||||
 | 
					      <Medal rarity={rarity} />
 | 
				
			||||||
 | 
					      <Tooltip
 | 
				
			||||||
 | 
					        text={`Make ${PAST_BETS} ${totalBettingStreak} day${
 | 
				
			||||||
 | 
					          totalBettingStreak > 1 ? 's' : ''
 | 
				
			||||||
 | 
					        } in a row`}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <span
 | 
				
			||||||
 | 
					          className={
 | 
				
			||||||
 | 
					            rarity === 'gold'
 | 
				
			||||||
 | 
					              ? goldClassName
 | 
				
			||||||
 | 
					              : rarity === 'silver'
 | 
				
			||||||
 | 
					              ? silverClassName
 | 
				
			||||||
 | 
					              : bronzeClassName
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Prediction Streak
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </Tooltip>
 | 
				
			||||||
 | 
					    </Col>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function MarketCreatorBadgeItem(props: {
 | 
				
			||||||
 | 
					  badge: MarketCreatorBadge
 | 
				
			||||||
 | 
					  rarity: rarities
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { badge, rarity } = props
 | 
				
			||||||
 | 
					  const { totalContractsCreated } = badge.data
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Col className={'cursor-default text-center'}>
 | 
				
			||||||
 | 
					      <Medal rarity={rarity} />
 | 
				
			||||||
 | 
					      <Tooltip
 | 
				
			||||||
 | 
					        text={`Make ${totalContractsCreated} market${
 | 
				
			||||||
 | 
					          totalContractsCreated > 1 ? 's' : ''
 | 
				
			||||||
 | 
					        }`}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <span
 | 
				
			||||||
 | 
					          className={
 | 
				
			||||||
 | 
					            rarity === 'gold'
 | 
				
			||||||
 | 
					              ? goldClassName
 | 
				
			||||||
 | 
					              : rarity === 'silver'
 | 
				
			||||||
 | 
					              ? silverClassName
 | 
				
			||||||
 | 
					              : bronzeClassName
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Market Creator
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </Tooltip>
 | 
				
			||||||
 | 
					    </Col>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function Medal(props: { rarity: rarities }) {
 | 
				
			||||||
 | 
					  const { rarity } = props
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      className={
 | 
				
			||||||
 | 
					        rarity === 'gold'
 | 
				
			||||||
 | 
					          ? goldClassName
 | 
				
			||||||
 | 
					          : rarity === 'silver'
 | 
				
			||||||
 | 
					          ? silverClassName
 | 
				
			||||||
 | 
					          : bronzeClassName
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -13,7 +13,7 @@ import clsx from 'clsx'
 | 
				
			||||||
export function BettingStreakModal(props: {
 | 
					export function BettingStreakModal(props: {
 | 
				
			||||||
  isOpen: boolean
 | 
					  isOpen: boolean
 | 
				
			||||||
  setOpen: (open: boolean) => void
 | 
					  setOpen: (open: boolean) => void
 | 
				
			||||||
  currentUser?: User | null
 | 
					  currentUser: User | null | undefined
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { isOpen, setOpen, currentUser } = props
 | 
					  const { isOpen, setOpen, currentUser } = props
 | 
				
			||||||
  const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
 | 
					  const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,8 +35,8 @@ export function LoansModal(props: {
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
          <span className={'text-indigo-700'}>• What is an example?</span>
 | 
					          <span className={'text-indigo-700'}>• What is an example?</span>
 | 
				
			||||||
          <span className={'ml-2'}>
 | 
					          <span className={'ml-2'}>
 | 
				
			||||||
            For example, if you bet M$1000 on "Will I become a millionare?"
 | 
					            For example, if you bet M$1000 on "Will I become a millionare?", you
 | 
				
			||||||
            today, you will get M$20 back tomorrow.
 | 
					            will get M$20 back tomorrow.
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
          <span className={'ml-2'}>
 | 
					          <span className={'ml-2'}>
 | 
				
			||||||
            Previous loans count against your total bet amount. So on the next
 | 
					            Previous loans count against your total bet amount. So on the next
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import { CPMMBinaryContract } from 'common/contract'
 | 
				
			||||||
import { Customize, USAMap } from './usa-map'
 | 
					import { Customize, USAMap } from './usa-map'
 | 
				
			||||||
import { listenForContract } from 'web/lib/firebase/contracts'
 | 
					import { listenForContract } from 'web/lib/firebase/contracts'
 | 
				
			||||||
import { interpolateColor } from 'common/util/color'
 | 
					import { interpolateColor } from 'common/util/color'
 | 
				
			||||||
 | 
					import { track } from 'web/lib/service/analytics'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface StateElectionMarket {
 | 
					export interface StateElectionMarket {
 | 
				
			||||||
  creatorUsername: string
 | 
					  creatorUsername: string
 | 
				
			||||||
| 
						 | 
					@ -35,8 +36,13 @@ export function StateElectionMap(props: {
 | 
				
			||||||
    market.state,
 | 
					    market.state,
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      fill: probToColor(prob, market.isWinRepublican),
 | 
					      fill: probToColor(prob, market.isWinRepublican),
 | 
				
			||||||
      clickHandler: () =>
 | 
					      clickHandler: () => {
 | 
				
			||||||
        Router.push(`/${market.creatorUsername}/${market.slug}`),
 | 
					        Router.push(`/${market.creatorUsername}/${market.slug}`)
 | 
				
			||||||
 | 
					        track('state election map click', {
 | 
				
			||||||
 | 
					          state: market.state,
 | 
				
			||||||
 | 
					          slug: market.slug,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  ])
 | 
					  ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
import { SiteLink } from 'web/components/site-link'
 | 
					import { SiteLink } from 'web/components/site-link'
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { useWindowSize } from 'web/hooks/use-window-size'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function shortenName(name: string) {
 | 
					export function shortenName(name: string) {
 | 
				
			||||||
  const firstName = name.split(' ')[0]
 | 
					  const firstName = name.split(' ')[0]
 | 
				
			||||||
| 
						 | 
					@ -24,10 +25,12 @@ export function UserLink(props: {
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { name, username, className, short, noLink } = props
 | 
					  const { name, username, className, short, noLink } = props
 | 
				
			||||||
  const shortName = short ? shortenName(name) : name
 | 
					  const shortName = short ? shortenName(name) : name
 | 
				
			||||||
 | 
					  const { width } = useWindowSize()
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <SiteLink
 | 
					    <SiteLink
 | 
				
			||||||
      href={`/${username}`}
 | 
					      href={`/${username}`}
 | 
				
			||||||
      className={clsx(
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        (width ?? 0) < 450 ? ' max-w-[120px]' : 'max-w-[200px]',
 | 
				
			||||||
        'z-10 truncate',
 | 
					        'z-10 truncate',
 | 
				
			||||||
        className,
 | 
					        className,
 | 
				
			||||||
        noLink ? 'pointer-events-none' : ''
 | 
					        noLink ? 'pointer-events-none' : ''
 | 
				
			||||||
| 
						 | 
					@ -39,7 +42,13 @@ export function UserLink(props: {
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const BOT_USERNAMES = ['v', 'ArbitrageBot']
 | 
					const BOT_USERNAMES = [
 | 
				
			||||||
 | 
					  'v',
 | 
				
			||||||
 | 
					  'ArbitrageBot',
 | 
				
			||||||
 | 
					  'MarketManagerBot',
 | 
				
			||||||
 | 
					  'Botlab',
 | 
				
			||||||
 | 
					  'JuniorBot',
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function BotBadge() {
 | 
					function BotBadge() {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -31,14 +31,12 @@ import { UserFollowButton } from './follow-button'
 | 
				
			||||||
import { GroupsButton } from 'web/components/groups/groups-button'
 | 
					import { GroupsButton } from 'web/components/groups/groups-button'
 | 
				
			||||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
 | 
					import { PortfolioValueSection } from './portfolio/portfolio-value-section'
 | 
				
			||||||
import { formatMoney } from 'common/util/format'
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
import {
 | 
					
 | 
				
			||||||
  BettingStreakModal,
 | 
					 | 
				
			||||||
  hasCompletedStreakToday,
 | 
					 | 
				
			||||||
} from 'web/components/profile/betting-streak-modal'
 | 
					 | 
				
			||||||
import { LoansModal } from './profile/loans-modal'
 | 
					import { LoansModal } from './profile/loans-modal'
 | 
				
			||||||
import { copyToClipboard } from 'web/lib/util/copy'
 | 
					import { copyToClipboard } from 'web/lib/util/copy'
 | 
				
			||||||
import { track } from 'web/lib/service/analytics'
 | 
					import { track } from 'web/lib/service/analytics'
 | 
				
			||||||
import { DOMAIN } from 'common/envs/constants'
 | 
					import { DOMAIN } from 'common/envs/constants'
 | 
				
			||||||
 | 
					import { BadgeDisplay } from 'web/components/badge-display'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function UserPage(props: { user: User }) {
 | 
					export function UserPage(props: { user: User }) {
 | 
				
			||||||
  const { user } = props
 | 
					  const { user } = props
 | 
				
			||||||
| 
						 | 
					@ -52,8 +50,8 @@ export function UserPage(props: { user: User }) {
 | 
				
			||||||
    setShowConfetti(claimedMana)
 | 
					    setShowConfetti(claimedMana)
 | 
				
			||||||
    const query = { ...router.query }
 | 
					    const query = { ...router.query }
 | 
				
			||||||
    if (query.claimedMana || query.show) {
 | 
					    if (query.claimedMana || query.show) {
 | 
				
			||||||
      delete query['claimed-mana']
 | 
					      const queriesToDelete = ['claimed-mana', 'show', 'badge']
 | 
				
			||||||
      delete query['show']
 | 
					      queriesToDelete.forEach((key) => delete query[key])
 | 
				
			||||||
      router.replace(
 | 
					      router.replace(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          pathname: router.pathname,
 | 
					          pathname: router.pathname,
 | 
				
			||||||
| 
						 | 
					@ -79,6 +77,7 @@ export function UserPage(props: { user: User }) {
 | 
				
			||||||
      {showConfetti && (
 | 
					      {showConfetti && (
 | 
				
			||||||
        <FullscreenConfetti recycle={false} numberOfPieces={300} />
 | 
					        <FullscreenConfetti recycle={false} numberOfPieces={300} />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Col className="relative">
 | 
					      <Col className="relative">
 | 
				
			||||||
        <Row className="relative px-4 pt-4">
 | 
					        <Row className="relative px-4 pt-4">
 | 
				
			||||||
          <Avatar
 | 
					          <Avatar
 | 
				
			||||||
| 
						 | 
					@ -101,9 +100,10 @@ export function UserPage(props: { user: User }) {
 | 
				
			||||||
                <span className="break-anywhere text-lg font-bold sm:text-2xl">
 | 
					                <span className="break-anywhere text-lg font-bold sm:text-2xl">
 | 
				
			||||||
                  {user.name}
 | 
					                  {user.name}
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
                <span className="sm:text-md text-greyscale-4 text-sm">
 | 
					                <Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm ">
 | 
				
			||||||
                  @{user.username}
 | 
					                  <span className={' text-greyscale-4'}>@{user.username}</span>
 | 
				
			||||||
                </span>
 | 
					                  <BadgeDisplay user={user} query={router.query} />
 | 
				
			||||||
 | 
					                </Row>
 | 
				
			||||||
              </Col>
 | 
					              </Col>
 | 
				
			||||||
              {isCurrentUser && (
 | 
					              {isCurrentUser && (
 | 
				
			||||||
                <ProfilePrivateStats
 | 
					                <ProfilePrivateStats
 | 
				
			||||||
| 
						 | 
					@ -278,14 +278,10 @@ export function ProfilePrivateStats(props: {
 | 
				
			||||||
  user: User
 | 
					  user: User
 | 
				
			||||||
  router: NextRouter
 | 
					  router: NextRouter
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { currentUser, profit, user, router } = props
 | 
					  const { profit, user, router } = props
 | 
				
			||||||
  const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
 | 
					 | 
				
			||||||
  const [showLoansModal, setShowLoansModal] = useState(false)
 | 
					  const [showLoansModal, setShowLoansModal] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const showBettingStreak = router.query['show'] === 'betting-streak'
 | 
					 | 
				
			||||||
    setShowBettingStreakModal(showBettingStreak)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const showLoansModel = router.query['show'] === 'loans'
 | 
					    const showLoansModel = router.query['show'] === 'loans'
 | 
				
			||||||
    setShowLoansModal(showLoansModel)
 | 
					    setShowLoansModal(showLoansModel)
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
| 
						 | 
					@ -301,23 +297,6 @@ export function ProfilePrivateStats(props: {
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
          <span className="mx-auto text-xs sm:text-sm">profit</span>
 | 
					          <span className="mx-auto text-xs sm:text-sm">profit</span>
 | 
				
			||||||
        </Col>
 | 
					        </Col>
 | 
				
			||||||
        <Col
 | 
					 | 
				
			||||||
          className={clsx('text-,d cursor-pointer sm:text-lg ')}
 | 
					 | 
				
			||||||
          onClick={() => setShowBettingStreakModal(true)}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <span
 | 
					 | 
				
			||||||
            className={clsx(
 | 
					 | 
				
			||||||
              !hasCompletedStreakToday(user)
 | 
					 | 
				
			||||||
                ? 'opacity-50 grayscale'
 | 
					 | 
				
			||||||
                : 'grayscale-0'
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            🔥 {user.currentBettingStreak ?? 0}
 | 
					 | 
				
			||||||
          </span>
 | 
					 | 
				
			||||||
          <span className="text-greyscale-4 mx-auto text-xs sm:text-sm">
 | 
					 | 
				
			||||||
            streak
 | 
					 | 
				
			||||||
          </span>
 | 
					 | 
				
			||||||
        </Col>
 | 
					 | 
				
			||||||
        <Col
 | 
					        <Col
 | 
				
			||||||
          className={
 | 
					          className={
 | 
				
			||||||
            'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
 | 
					            'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
 | 
				
			||||||
| 
						 | 
					@ -330,13 +309,6 @@ export function ProfilePrivateStats(props: {
 | 
				
			||||||
          <span className="mx-auto text-xs sm:text-sm">next loan</span>
 | 
					          <span className="mx-auto text-xs sm:text-sm">next loan</span>
 | 
				
			||||||
        </Col>
 | 
					        </Col>
 | 
				
			||||||
      </Row>
 | 
					      </Row>
 | 
				
			||||||
      {BettingStreakModal && (
 | 
					 | 
				
			||||||
        <BettingStreakModal
 | 
					 | 
				
			||||||
          isOpen={showBettingStreakModal}
 | 
					 | 
				
			||||||
          setOpen={setShowBettingStreakModal}
 | 
					 | 
				
			||||||
          currentUser={currentUser}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      {showLoansModal && (
 | 
					      {showLoansModal && (
 | 
				
			||||||
        <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
 | 
					        <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,6 +14,7 @@
 | 
				
			||||||
    "build": "next build",
 | 
					    "build": "next build",
 | 
				
			||||||
    "start": "next start",
 | 
					    "start": "next start",
 | 
				
			||||||
    "lint": "next lint",
 | 
					    "lint": "next lint",
 | 
				
			||||||
 | 
					    "lint-fix": "next lint --fix",
 | 
				
			||||||
    "format": "npx prettier --write .",
 | 
					    "format": "npx prettier --write .",
 | 
				
			||||||
    "verify": "(cd .. && yarn verify)",
 | 
					    "verify": "(cd .. && yarn verify)",
 | 
				
			||||||
    "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
 | 
					    "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -270,7 +270,11 @@ export function ContractPageContent(
 | 
				
			||||||
          <>
 | 
					          <>
 | 
				
			||||||
            <div className="grid grid-cols-1 sm:grid-cols-2">
 | 
					            <div className="grid grid-cols-1 sm:grid-cols-2">
 | 
				
			||||||
              <ContractLeaderboard contract={contract} bets={bets} />
 | 
					              <ContractLeaderboard contract={contract} bets={bets} />
 | 
				
			||||||
              <ContractTopTrades contract={contract} bets={bets} />
 | 
					              <ContractTopTrades
 | 
				
			||||||
 | 
					                contract={contract}
 | 
				
			||||||
 | 
					                bets={bets}
 | 
				
			||||||
 | 
					                comments={comments}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <Spacer h={12} />
 | 
					            <Spacer h={12} />
 | 
				
			||||||
          </>
 | 
					          </>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -192,7 +192,6 @@ export type LiteUser = {
 | 
				
			||||||
  avatarUrl?: string
 | 
					  avatarUrl?: string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bio?: string
 | 
					  bio?: string
 | 
				
			||||||
  bannerUrl?: string
 | 
					 | 
				
			||||||
  website?: string
 | 
					  website?: string
 | 
				
			||||||
  twitterHandle?: string
 | 
					  twitterHandle?: string
 | 
				
			||||||
  discordHandle?: string
 | 
					  discordHandle?: string
 | 
				
			||||||
| 
						 | 
					@ -223,7 +222,6 @@ export function toLiteUser(user: User): LiteUser {
 | 
				
			||||||
    username,
 | 
					    username,
 | 
				
			||||||
    avatarUrl,
 | 
					    avatarUrl,
 | 
				
			||||||
    bio,
 | 
					    bio,
 | 
				
			||||||
    bannerUrl,
 | 
					 | 
				
			||||||
    website,
 | 
					    website,
 | 
				
			||||||
    twitterHandle,
 | 
					    twitterHandle,
 | 
				
			||||||
    discordHandle,
 | 
					    discordHandle,
 | 
				
			||||||
| 
						 | 
					@ -241,7 +239,6 @@ export function toLiteUser(user: User): LiteUser {
 | 
				
			||||||
    url: `https://${ENV_CONFIG.domain}/${username}`,
 | 
					    url: `https://${ENV_CONFIG.domain}/${username}`,
 | 
				
			||||||
    avatarUrl,
 | 
					    avatarUrl,
 | 
				
			||||||
    bio,
 | 
					    bio,
 | 
				
			||||||
    bannerUrl,
 | 
					 | 
				
			||||||
    website,
 | 
					    website,
 | 
				
			||||||
    twitterHandle,
 | 
					    twitterHandle,
 | 
				
			||||||
    discordHandle,
 | 
					    discordHandle,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users'
 | 
				
			||||||
import { SiteLink } from 'web/components/site-link'
 | 
					import { SiteLink } from 'web/components/site-link'
 | 
				
			||||||
import { User } from 'common/user'
 | 
					import { User } from 'common/user'
 | 
				
			||||||
import { SEO } from 'web/components/SEO'
 | 
					import { SEO } from 'web/components/SEO'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function getStaticProps() {
 | 
					export async function getStaticProps() {
 | 
				
			||||||
  let txns = await getAllCharityTxns()
 | 
					  let txns = await getAllCharityTxns()
 | 
				
			||||||
| 
						 | 
					@ -171,11 +172,11 @@ export default function Charity(props: {
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <Spacer h={10} />
 | 
					          <Spacer h={10} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
            onChange={(e) => debouncedQuery(e.target.value)}
 | 
					            onChange={(e) => debouncedQuery(e.target.value)}
 | 
				
			||||||
            placeholder="Find a charity"
 | 
					            placeholder="Find a charity"
 | 
				
			||||||
            className="input input-bordered mb-6 w-full"
 | 
					            className="mb-6 w-full"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </Col>
 | 
					        </Col>
 | 
				
			||||||
        <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
 | 
					        <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,7 @@ import {
 | 
				
			||||||
  urlParamStore,
 | 
					  urlParamStore,
 | 
				
			||||||
} from 'web/hooks/use-persistent-state'
 | 
					} from 'web/hooks/use-persistent-state'
 | 
				
			||||||
import { PAST_BETS } from 'common/user'
 | 
					import { PAST_BETS } from 'common/user'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const MAX_CONTRACTS_RENDERED = 100
 | 
					const MAX_CONTRACTS_RENDERED = 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: {
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      {/* Show a search input next to a sort dropdown */}
 | 
					      {/* Show a search input next to a sort dropdown */}
 | 
				
			||||||
      <div className="mt-2 mb-8 flex justify-between gap-2">
 | 
					      <div className="mt-2 mb-8 flex justify-between gap-2">
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          value={query}
 | 
					          value={query}
 | 
				
			||||||
          onChange={(e) => setQuery(e.target.value)}
 | 
					          onChange={(e) => setQuery(e.target.value)}
 | 
				
			||||||
          placeholder="Search markets"
 | 
					          placeholder="Search markets"
 | 
				
			||||||
          className="input input-bordered w-full"
 | 
					          className="w-full"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <select
 | 
					        <select
 | 
				
			||||||
          className="select select-bordered"
 | 
					          className="select select-bordered"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router'
 | 
				
			||||||
import { useEffect, useState } from 'react'
 | 
					import { useEffect, useState } from 'react'
 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import dayjs from 'dayjs'
 | 
					import dayjs from 'dayjs'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import { Spacer } from 'web/components/layout/spacer'
 | 
					import { Spacer } from 'web/components/layout/spacer'
 | 
				
			||||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
 | 
					import { getUserAndPrivateUser } from 'web/lib/firebase/users'
 | 
				
			||||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
 | 
					import { Contract, contractPath } from 'web/lib/firebase/contracts'
 | 
				
			||||||
| 
						 | 
					@ -38,6 +37,8 @@ import { SiteLink } from 'web/components/site-link'
 | 
				
			||||||
import { Button } from 'web/components/button'
 | 
					import { Button } from 'web/components/button'
 | 
				
			||||||
import { AddFundsModal } from 'web/components/add-funds-modal'
 | 
					import { AddFundsModal } from 'web/components/add-funds-modal'
 | 
				
			||||||
import ShortToggle from 'web/components/widgets/short-toggle'
 | 
					import ShortToggle from 'web/components/widgets/short-toggle'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					import { ExpandingInput } from 'web/components/expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
 | 
					export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
 | 
				
			||||||
  return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
 | 
					  return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
 | 
				
			||||||
| 
						 | 
					@ -103,9 +104,8 @@ export default function Create(props: { auth: { user: User } }) {
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              </label>
 | 
					              </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <Textarea
 | 
					              <ExpandingInput
 | 
				
			||||||
                placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
 | 
					                placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
 | 
				
			||||||
                className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
                autoFocus
 | 
					                autoFocus
 | 
				
			||||||
                maxLength={MAX_QUESTION_LENGTH}
 | 
					                maxLength={MAX_QUESTION_LENGTH}
 | 
				
			||||||
                value={question}
 | 
					                value={question}
 | 
				
			||||||
| 
						 | 
					@ -327,9 +327,9 @@ export function NewContract(props: {
 | 
				
			||||||
            </label>
 | 
					            </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <Row className="gap-2">
 | 
					            <Row className="gap-2">
 | 
				
			||||||
              <input
 | 
					              <Input
 | 
				
			||||||
                type="number"
 | 
					                type="number"
 | 
				
			||||||
                className="input input-bordered w-32"
 | 
					                className="w-32"
 | 
				
			||||||
                placeholder="LOW"
 | 
					                placeholder="LOW"
 | 
				
			||||||
                onClick={(e) => e.stopPropagation()}
 | 
					                onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
                onChange={(e) => setMinString(e.target.value)}
 | 
					                onChange={(e) => setMinString(e.target.value)}
 | 
				
			||||||
| 
						 | 
					@ -338,9 +338,9 @@ export function NewContract(props: {
 | 
				
			||||||
                disabled={isSubmitting}
 | 
					                disabled={isSubmitting}
 | 
				
			||||||
                value={minString ?? ''}
 | 
					                value={minString ?? ''}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
              <input
 | 
					              <Input
 | 
				
			||||||
                type="number"
 | 
					                type="number"
 | 
				
			||||||
                className="input input-bordered w-32"
 | 
					                className="w-32"
 | 
				
			||||||
                placeholder="HIGH"
 | 
					                placeholder="HIGH"
 | 
				
			||||||
                onClick={(e) => e.stopPropagation()}
 | 
					                onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
                onChange={(e) => setMaxString(e.target.value)}
 | 
					                onChange={(e) => setMaxString(e.target.value)}
 | 
				
			||||||
| 
						 | 
					@ -372,9 +372,8 @@ export function NewContract(props: {
 | 
				
			||||||
            </label>
 | 
					            </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <Row className="gap-2">
 | 
					            <Row className="gap-2">
 | 
				
			||||||
              <input
 | 
					              <Input
 | 
				
			||||||
                type="number"
 | 
					                type="number"
 | 
				
			||||||
                className="input input-bordered"
 | 
					 | 
				
			||||||
                placeholder="Initial value"
 | 
					                placeholder="Initial value"
 | 
				
			||||||
                onClick={(e) => e.stopPropagation()}
 | 
					                onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
                onChange={(e) => setInitialValueString(e.target.value)}
 | 
					                onChange={(e) => setInitialValueString(e.target.value)}
 | 
				
			||||||
| 
						 | 
					@ -444,19 +443,17 @@ export function NewContract(props: {
 | 
				
			||||||
            className={'col-span-4 sm:col-span-2'}
 | 
					            className={'col-span-4 sm:col-span-2'}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </Row>
 | 
					        </Row>
 | 
				
			||||||
        <Row>
 | 
					        <Row className="mt-4 gap-2">
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            type={'date'}
 | 
					            type={'date'}
 | 
				
			||||||
            className="input input-bordered mt-4"
 | 
					 | 
				
			||||||
            onClick={(e) => e.stopPropagation()}
 | 
					            onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
            onChange={(e) => setCloseDate(e.target.value)}
 | 
					            onChange={(e) => setCloseDate(e.target.value)}
 | 
				
			||||||
            min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
 | 
					            min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
 | 
				
			||||||
            disabled={isSubmitting}
 | 
					            disabled={isSubmitting}
 | 
				
			||||||
            value={closeDate}
 | 
					            value={closeDate}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            type={'time'}
 | 
					            type={'time'}
 | 
				
			||||||
            className="input input-bordered mt-4 ml-2"
 | 
					 | 
				
			||||||
            onClick={(e) => e.stopPropagation()}
 | 
					            onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
            onChange={(e) => setCloseHoursMinutes(e.target.value)}
 | 
					            onChange={(e) => setCloseHoursMinutes(e.target.value)}
 | 
				
			||||||
            min={'00:00'}
 | 
					            min={'00:00'}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,5 @@
 | 
				
			||||||
import Router from 'next/router'
 | 
					import Router from 'next/router'
 | 
				
			||||||
import { useEffect, useState } from 'react'
 | 
					import { useEffect, useState } from 'react'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { DateDoc } from 'common/post'
 | 
					import { DateDoc } from 'common/post'
 | 
				
			||||||
import { useTextEditor, TextEditor } from 'web/components/editor'
 | 
					import { useTextEditor, TextEditor } from 'web/components/editor'
 | 
				
			||||||
import { Page } from 'web/components/page'
 | 
					import { Page } from 'web/components/page'
 | 
				
			||||||
| 
						 | 
					@ -17,6 +15,8 @@ import { MAX_QUESTION_LENGTH } from 'common/contract'
 | 
				
			||||||
import { NoSEO } from 'web/components/NoSEO'
 | 
					import { NoSEO } from 'web/components/NoSEO'
 | 
				
			||||||
import ShortToggle from 'web/components/widgets/short-toggle'
 | 
					import ShortToggle from 'web/components/widgets/short-toggle'
 | 
				
			||||||
import { removeUndefinedProps } from 'common/util/object'
 | 
					import { removeUndefinedProps } from 'common/util/object'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					import { ExpandingInput } from 'web/components/expanding-input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function CreateDateDocPage() {
 | 
					export default function CreateDateDocPage() {
 | 
				
			||||||
  const user = useUser()
 | 
					  const user = useUser()
 | 
				
			||||||
| 
						 | 
					@ -94,9 +94,8 @@ export default function CreateDateDocPage() {
 | 
				
			||||||
          <Col className="gap-8">
 | 
					          <Col className="gap-8">
 | 
				
			||||||
            <Col className="max-w-[160px] justify-start gap-4">
 | 
					            <Col className="max-w-[160px] justify-start gap-4">
 | 
				
			||||||
              <div className="">Birthday</div>
 | 
					              <div className="">Birthday</div>
 | 
				
			||||||
              <input
 | 
					              <Input
 | 
				
			||||||
                type={'date'}
 | 
					                type={'date'}
 | 
				
			||||||
                className="input input-bordered"
 | 
					 | 
				
			||||||
                onClick={(e) => e.stopPropagation()}
 | 
					                onClick={(e) => e.stopPropagation()}
 | 
				
			||||||
                onChange={(e) => setBirthday(e.target.value)}
 | 
					                onChange={(e) => setBirthday(e.target.value)}
 | 
				
			||||||
                max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
 | 
					                max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
 | 
				
			||||||
| 
						 | 
					@ -122,8 +121,7 @@ export default function CreateDateDocPage() {
 | 
				
			||||||
              </Row>
 | 
					              </Row>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <Col className="gap-2">
 | 
					              <Col className="gap-2">
 | 
				
			||||||
                <Textarea
 | 
					                <ExpandingInput
 | 
				
			||||||
                  className="input input-bordered resize-none"
 | 
					 | 
				
			||||||
                  maxLength={MAX_QUESTION_LENGTH}
 | 
					                  maxLength={MAX_QUESTION_LENGTH}
 | 
				
			||||||
                  value={question}
 | 
					                  value={question}
 | 
				
			||||||
                  onChange={(e) => setQuestion(e.target.value || '')}
 | 
					                  onChange={(e) => setQuestion(e.target.value || '')}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,6 +14,7 @@ import { getUser, User } from 'web/lib/firebase/users'
 | 
				
			||||||
import { DateDocPost } from './[username]'
 | 
					import { DateDocPost } from './[username]'
 | 
				
			||||||
import { NoSEO } from 'web/components/NoSEO'
 | 
					import { NoSEO } from 'web/components/NoSEO'
 | 
				
			||||||
import { useDateDocs } from 'web/hooks/use-post'
 | 
					import { useDateDocs } from 'web/hooks/use-post'
 | 
				
			||||||
 | 
					import { useTracking } from 'web/hooks/use-tracking'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function getStaticProps() {
 | 
					export async function getStaticProps() {
 | 
				
			||||||
  const dateDocs = await getDateDocs()
 | 
					  const dateDocs = await getDateDocs()
 | 
				
			||||||
| 
						 | 
					@ -40,6 +41,7 @@ export default function DatePage(props: {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const dateDocs = useDateDocs() ?? props.dateDocs
 | 
					  const dateDocs = useDateDocs() ?? props.dateDocs
 | 
				
			||||||
  const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
 | 
					  const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
 | 
				
			||||||
 | 
					  useTracking('view date docs page')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Page>
 | 
					    <Page>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO'
 | 
				
			||||||
import { GetServerSideProps } from 'next'
 | 
					import { GetServerSideProps } from 'next'
 | 
				
			||||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
 | 
					import { authenticateOnServer } from 'web/lib/firebase/server-auth'
 | 
				
			||||||
import { useUser } from 'web/hooks/use-user'
 | 
					import { useUser } from 'web/hooks/use-user'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
 | 
					export const getServerSideProps: GetServerSideProps = async (ctx) => {
 | 
				
			||||||
  const creds = await authenticateOnServer(ctx)
 | 
					  const creds = await authenticateOnServer(ctx)
 | 
				
			||||||
| 
						 | 
					@ -106,12 +107,12 @@ export default function Groups(props: {
 | 
				
			||||||
                title: 'All',
 | 
					                title: 'All',
 | 
				
			||||||
                content: (
 | 
					                content: (
 | 
				
			||||||
                  <Col>
 | 
					                  <Col>
 | 
				
			||||||
                    <input
 | 
					                    <Input
 | 
				
			||||||
                      type="text"
 | 
					                      type="text"
 | 
				
			||||||
                      onChange={(e) => debouncedQuery(e.target.value)}
 | 
					                      onChange={(e) => debouncedQuery(e.target.value)}
 | 
				
			||||||
                      placeholder="Search groups"
 | 
					                      placeholder="Search groups"
 | 
				
			||||||
                      value={query}
 | 
					                      value={query}
 | 
				
			||||||
                      className="input input-bordered mb-4 w-full"
 | 
					                      className="mb-4 w-full"
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <div className="flex flex-wrap justify-center gap-4">
 | 
					                    <div className="flex flex-wrap justify-center gap-4">
 | 
				
			||||||
| 
						 | 
					@ -134,12 +135,12 @@ export default function Groups(props: {
 | 
				
			||||||
                      title: 'My Groups',
 | 
					                      title: 'My Groups',
 | 
				
			||||||
                      content: (
 | 
					                      content: (
 | 
				
			||||||
                        <Col>
 | 
					                        <Col>
 | 
				
			||||||
                          <input
 | 
					                          <Input
 | 
				
			||||||
                            type="text"
 | 
					                            type="text"
 | 
				
			||||||
                            value={query}
 | 
					                            value={query}
 | 
				
			||||||
                            onChange={(e) => debouncedQuery(e.target.value)}
 | 
					                            onChange={(e) => debouncedQuery(e.target.value)}
 | 
				
			||||||
                            placeholder="Search your groups"
 | 
					                            placeholder="Search your groups"
 | 
				
			||||||
                            className="input input-bordered mb-4 w-full"
 | 
					                            className="mb-4 w-full"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                          <div className="flex flex-wrap justify-center gap-4">
 | 
					                          <div className="flex flex-wrap justify-center gap-4">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -48,6 +48,7 @@ import {
 | 
				
			||||||
} from 'web/hooks/use-contracts'
 | 
					} from 'web/hooks/use-contracts'
 | 
				
			||||||
import { ProfitBadge } from 'web/components/profit-badge'
 | 
					import { ProfitBadge } from 'web/components/profit-badge'
 | 
				
			||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
 | 
					import { LoadingIndicator } from 'web/components/loading-indicator'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Home() {
 | 
					export default function Home() {
 | 
				
			||||||
  const user = useUser()
 | 
					  const user = useUser()
 | 
				
			||||||
| 
						 | 
					@ -99,10 +100,10 @@ export default function Home() {
 | 
				
			||||||
        <Row
 | 
					        <Row
 | 
				
			||||||
          className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
 | 
					          className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
            placeholder={'Search'}
 | 
					            placeholder={'Search'}
 | 
				
			||||||
            className="input input-bordered w-full"
 | 
					            className="w-full"
 | 
				
			||||||
            onClick={() => Router.push('/search')}
 | 
					            onClick={() => Router.push('/search')}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <CustomizeButton justIcon />
 | 
					          <CustomizeButton justIcon />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import {
 | 
				
			||||||
  StateElectionMarket,
 | 
					  StateElectionMarket,
 | 
				
			||||||
  StateElectionMap,
 | 
					  StateElectionMap,
 | 
				
			||||||
} from 'web/components/usa-map/state-election-map'
 | 
					} from 'web/components/usa-map/state-election-map'
 | 
				
			||||||
 | 
					import { useTracking } from 'web/hooks/use-tracking'
 | 
				
			||||||
import { getContractFromSlug } from 'web/lib/firebase/contracts'
 | 
					import { getContractFromSlug } from 'web/lib/firebase/contracts'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const senateMidterms: StateElectionMarket[] = [
 | 
					const senateMidterms: StateElectionMarket[] = [
 | 
				
			||||||
| 
						 | 
					@ -203,6 +204,8 @@ const App = (props: {
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const { senateContracts, governorContracts } = props
 | 
					  const { senateContracts, governorContracts } = props
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useTracking('view midterms 2022')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Page className="">
 | 
					    <Page className="">
 | 
				
			||||||
      <Col className="items-center justify-center">
 | 
					      <Col className="items-center justify-center">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,11 +13,7 @@ import { Page } from 'web/components/page'
 | 
				
			||||||
import { Title } from 'web/components/title'
 | 
					import { Title } from 'web/components/title'
 | 
				
			||||||
import { doc, updateDoc } from 'firebase/firestore'
 | 
					import { doc, updateDoc } from 'firebase/firestore'
 | 
				
			||||||
import { db } from 'web/lib/firebase/init'
 | 
					import { db } from 'web/lib/firebase/init'
 | 
				
			||||||
import {
 | 
					import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user'
 | 
				
			||||||
  MANIFOLD_AVATAR_URL,
 | 
					 | 
				
			||||||
  MANIFOLD_USERNAME,
 | 
					 | 
				
			||||||
  PrivateUser,
 | 
					 | 
				
			||||||
} from 'common/user'
 | 
					 | 
				
			||||||
import clsx from 'clsx'
 | 
					import clsx from 'clsx'
 | 
				
			||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
 | 
					import { RelativeTimestamp } from 'web/components/relative-timestamp'
 | 
				
			||||||
import { Linkify } from 'web/components/linkify'
 | 
					import { Linkify } from 'web/components/linkify'
 | 
				
			||||||
| 
						 | 
					@ -469,8 +465,11 @@ function IncomeNotificationItem(props: {
 | 
				
			||||||
          simple ? (
 | 
					          simple ? (
 | 
				
			||||||
            <span className={'ml-1 font-bold'}>🏦 Loan</span>
 | 
					            <span className={'ml-1 font-bold'}>🏦 Loan</span>
 | 
				
			||||||
          ) : (
 | 
					          ) : (
 | 
				
			||||||
            <SiteLink className={'ml-1 font-bold'} href={'/loans'}>
 | 
					            <SiteLink
 | 
				
			||||||
              🏦 Loan
 | 
					              className={'relative ml-1 font-bold'}
 | 
				
			||||||
 | 
					              href={`/${sourceUserUsername}/?show=loans`}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              🏦 Loan <span className="font-normal">(learn more)</span>
 | 
				
			||||||
            </SiteLink>
 | 
					            </SiteLink>
 | 
				
			||||||
          )
 | 
					          )
 | 
				
			||||||
        ) : sourceType === 'betting_streak_bonus' ? (
 | 
					        ) : sourceType === 'betting_streak_bonus' ? (
 | 
				
			||||||
| 
						 | 
					@ -478,8 +477,8 @@ function IncomeNotificationItem(props: {
 | 
				
			||||||
            <span className={'ml-1 font-bold'}>{bettingStreakText}</span>
 | 
					            <span className={'ml-1 font-bold'}>{bettingStreakText}</span>
 | 
				
			||||||
          ) : (
 | 
					          ) : (
 | 
				
			||||||
            <SiteLink
 | 
					            <SiteLink
 | 
				
			||||||
              className={'ml-1 font-bold'}
 | 
					              className={'relative ml-1 font-bold'}
 | 
				
			||||||
              href={'/betting-streak-bonus'}
 | 
					              href={`/${sourceUserUsername}/?show=betting-streak`}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              {bettingStreakText}
 | 
					              {bettingStreakText}
 | 
				
			||||||
            </SiteLink>
 | 
					            </SiteLink>
 | 
				
			||||||
| 
						 | 
					@ -736,6 +735,24 @@ function NotificationItem(props: {
 | 
				
			||||||
        justSummary={justSummary}
 | 
					        justSummary={justSummary}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					  } else if (sourceType === 'badge') {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <BadgeNotification
 | 
				
			||||||
 | 
					        notification={notification}
 | 
				
			||||||
 | 
					        isChildOfGroup={isChildOfGroup}
 | 
				
			||||||
 | 
					        highlighted={highlighted}
 | 
				
			||||||
 | 
					        justSummary={justSummary}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  } else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <MarketClosedNotification
 | 
				
			||||||
 | 
					        notification={notification}
 | 
				
			||||||
 | 
					        isChildOfGroup={isChildOfGroup}
 | 
				
			||||||
 | 
					        highlighted={highlighted}
 | 
				
			||||||
 | 
					        justSummary={justSummary}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  // TODO Add new notification components here
 | 
					  // TODO Add new notification components here
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -809,9 +826,18 @@ function NotificationFrame(props: {
 | 
				
			||||||
  subtitle: string
 | 
					  subtitle: string
 | 
				
			||||||
  children: React.ReactNode
 | 
					  children: React.ReactNode
 | 
				
			||||||
  isChildOfGroup?: boolean
 | 
					  isChildOfGroup?: boolean
 | 
				
			||||||
 | 
					  hideUserName?: boolean
 | 
				
			||||||
 | 
					  hideLinkToGroupOrQuestion?: boolean
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { notification, isChildOfGroup, highlighted, subtitle, children } =
 | 
					  const {
 | 
				
			||||||
    props
 | 
					    notification,
 | 
				
			||||||
 | 
					    isChildOfGroup,
 | 
				
			||||||
 | 
					    highlighted,
 | 
				
			||||||
 | 
					    subtitle,
 | 
				
			||||||
 | 
					    children,
 | 
				
			||||||
 | 
					    hideUserName,
 | 
				
			||||||
 | 
					    hideLinkToGroupOrQuestion,
 | 
				
			||||||
 | 
					  } = props
 | 
				
			||||||
  const {
 | 
					  const {
 | 
				
			||||||
    sourceType,
 | 
					    sourceType,
 | 
				
			||||||
    sourceUserName,
 | 
					    sourceUserName,
 | 
				
			||||||
| 
						 | 
					@ -822,7 +848,7 @@ function NotificationFrame(props: {
 | 
				
			||||||
    sourceUserUsername,
 | 
					    sourceUserUsername,
 | 
				
			||||||
    sourceText,
 | 
					    sourceText,
 | 
				
			||||||
  } = notification
 | 
					  } = notification
 | 
				
			||||||
  const questionNeedsResolution = sourceUpdateType == 'closed'
 | 
					
 | 
				
			||||||
  const { width } = useWindowSize()
 | 
					  const { width } = useWindowSize()
 | 
				
			||||||
  const isMobile = (width ?? 0) < 600
 | 
					  const isMobile = (width ?? 0) < 600
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -852,16 +878,10 @@ function NotificationFrame(props: {
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <Row className={'items-center text-gray-500 sm:justify-start'}>
 | 
					        <Row className={'items-center text-gray-500 sm:justify-start'}>
 | 
				
			||||||
          <Avatar
 | 
					          <Avatar
 | 
				
			||||||
            avatarUrl={
 | 
					            avatarUrl={sourceUserAvatarUrl}
 | 
				
			||||||
              questionNeedsResolution
 | 
					 | 
				
			||||||
                ? MANIFOLD_AVATAR_URL
 | 
					 | 
				
			||||||
                : sourceUserAvatarUrl
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            size={'sm'}
 | 
					            size={'sm'}
 | 
				
			||||||
            className={'z-10 mr-2'}
 | 
					            className={'z-10 mr-2'}
 | 
				
			||||||
            username={
 | 
					            username={sourceUserUsername}
 | 
				
			||||||
              questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <div className={'flex w-full flex-row pl-1 sm:pl-0'}>
 | 
					          <div className={'flex w-full flex-row pl-1 sm:pl-0'}>
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
| 
						 | 
					@ -870,17 +890,21 @@ function NotificationFrame(props: {
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <UserLink
 | 
					                {!hideUserName && (
 | 
				
			||||||
                  name={sourceUserName || ''}
 | 
					                  <UserLink
 | 
				
			||||||
                  username={sourceUserUsername || ''}
 | 
					                    name={sourceUserName || ''}
 | 
				
			||||||
                  className={'relative mr-1 flex-shrink-0'}
 | 
					                    username={sourceUserUsername || ''}
 | 
				
			||||||
                  short={isMobile}
 | 
					                    className={'relative mr-1 flex-shrink-0'}
 | 
				
			||||||
                />
 | 
					                    short={isMobile}
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
                {subtitle}
 | 
					                {subtitle}
 | 
				
			||||||
                {isChildOfGroup ? (
 | 
					                {isChildOfGroup ? (
 | 
				
			||||||
                  <RelativeTimestamp time={notification.createdTime} />
 | 
					                  <RelativeTimestamp time={notification.createdTime} />
 | 
				
			||||||
                ) : (
 | 
					                ) : (
 | 
				
			||||||
                  <QuestionOrGroupLink notification={notification} />
 | 
					                  !hideLinkToGroupOrQuestion && (
 | 
				
			||||||
 | 
					                    <QuestionOrGroupLink notification={notification} />
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
                )}
 | 
					                )}
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
| 
						 | 
					@ -964,6 +988,66 @@ function BetFillNotification(props: {
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function MarketClosedNotification(props: {
 | 
				
			||||||
 | 
					  notification: Notification
 | 
				
			||||||
 | 
					  highlighted: boolean
 | 
				
			||||||
 | 
					  justSummary: boolean
 | 
				
			||||||
 | 
					  isChildOfGroup?: boolean
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { notification, isChildOfGroup, highlighted } = props
 | 
				
			||||||
 | 
					  notification.sourceUserAvatarUrl = MANIFOLD_AVATAR_URL
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <NotificationFrame
 | 
				
			||||||
 | 
					      notification={notification}
 | 
				
			||||||
 | 
					      isChildOfGroup={isChildOfGroup}
 | 
				
			||||||
 | 
					      highlighted={highlighted}
 | 
				
			||||||
 | 
					      subtitle={'Please resolve'}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Row>
 | 
				
			||||||
 | 
					        <span>
 | 
				
			||||||
 | 
					          {`Your market has closed. Please resolve it to pay out ${PAST_BETS}.`}
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </Row>
 | 
				
			||||||
 | 
					    </NotificationFrame>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function BadgeNotification(props: {
 | 
				
			||||||
 | 
					  notification: Notification
 | 
				
			||||||
 | 
					  highlighted: boolean
 | 
				
			||||||
 | 
					  justSummary: boolean
 | 
				
			||||||
 | 
					  isChildOfGroup?: boolean
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const { notification, isChildOfGroup, highlighted, justSummary } = props
 | 
				
			||||||
 | 
					  const { sourceText } = notification
 | 
				
			||||||
 | 
					  const subtitle = 'You earned a new badge!'
 | 
				
			||||||
 | 
					  notification.sourceUserAvatarUrl = '/award.svg'
 | 
				
			||||||
 | 
					  if (justSummary) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <NotificationSummaryFrame notification={notification} subtitle={subtitle}>
 | 
				
			||||||
 | 
					        <Row className={'line-clamp-1'}>
 | 
				
			||||||
 | 
					          <span>{sourceText} 🎉</span>
 | 
				
			||||||
 | 
					        </Row>
 | 
				
			||||||
 | 
					      </NotificationSummaryFrame>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <NotificationFrame
 | 
				
			||||||
 | 
					      notification={notification}
 | 
				
			||||||
 | 
					      isChildOfGroup={isChildOfGroup}
 | 
				
			||||||
 | 
					      highlighted={highlighted}
 | 
				
			||||||
 | 
					      subtitle={subtitle}
 | 
				
			||||||
 | 
					      hideUserName={true}
 | 
				
			||||||
 | 
					      hideLinkToGroupOrQuestion={true}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Row>
 | 
				
			||||||
 | 
					        <span>{sourceText} 🎉</span>
 | 
				
			||||||
 | 
					      </Row>
 | 
				
			||||||
 | 
					    </NotificationFrame>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function ContractResolvedNotification(props: {
 | 
					function ContractResolvedNotification(props: {
 | 
				
			||||||
  notification: Notification
 | 
					  notification: Notification
 | 
				
			||||||
  highlighted: boolean
 | 
					  highlighted: boolean
 | 
				
			||||||
| 
						 | 
					@ -1134,6 +1218,11 @@ function getSourceUrl(notification: Notification) {
 | 
				
			||||||
      sourceId ?? '',
 | 
					      sourceId ?? '',
 | 
				
			||||||
      sourceType
 | 
					      sourceType
 | 
				
			||||||
    )}`
 | 
					    )}`
 | 
				
			||||||
 | 
					  else if (sourceSlug)
 | 
				
			||||||
 | 
					    return `/${sourceSlug}#${getSourceIdForLinkComponent(
 | 
				
			||||||
 | 
					      sourceId ?? '',
 | 
				
			||||||
 | 
					      sourceType
 | 
				
			||||||
 | 
					    )}`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function getSourceIdForLinkComponent(
 | 
					function getSourceIdForLinkComponent(
 | 
				
			||||||
| 
						 | 
					@ -1233,7 +1322,6 @@ function getReasonForShowingNotification(
 | 
				
			||||||
          reasonText = justSummary ? 'asked the question' : 'asked'
 | 
					          reasonText = justSummary ? 'asked the question' : 'asked'
 | 
				
			||||||
        else if (sourceUpdateType === 'resolved')
 | 
					        else if (sourceUpdateType === 'resolved')
 | 
				
			||||||
          reasonText = justSummary ? `resolved the question` : `resolved`
 | 
					          reasonText = justSummary ? `resolved the question` : `resolved`
 | 
				
			||||||
        else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
 | 
					 | 
				
			||||||
        else reasonText = justSummary ? 'updated the question' : `updated`
 | 
					        else reasonText = justSummary ? 'updated the question' : `updated`
 | 
				
			||||||
        break
 | 
					        break
 | 
				
			||||||
      case 'answer':
 | 
					      case 'answer':
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user'
 | 
				
			||||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
 | 
					import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
 | 
				
			||||||
import Link from 'next/link'
 | 
					import Link from 'next/link'
 | 
				
			||||||
import React, { useState } from 'react'
 | 
					import React, { useState } from 'react'
 | 
				
			||||||
import Textarea from 'react-expanding-textarea'
 | 
					 | 
				
			||||||
import { ConfirmationButton } from 'web/components/confirmation-button'
 | 
					import { ConfirmationButton } from 'web/components/confirmation-button'
 | 
				
			||||||
 | 
					import { ExpandingInput } from 'web/components/expanding-input'
 | 
				
			||||||
 | 
					import { Input } from 'web/components/input'
 | 
				
			||||||
import { Col } from 'web/components/layout/col'
 | 
					import { Col } from 'web/components/layout/col'
 | 
				
			||||||
import { Row } from 'web/components/layout/row'
 | 
					import { Row } from 'web/components/layout/row'
 | 
				
			||||||
import { Page } from 'web/components/page'
 | 
					import { Page } from 'web/components/page'
 | 
				
			||||||
| 
						 | 
					@ -27,7 +28,7 @@ export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function EditUserField(props: {
 | 
					function EditUserField(props: {
 | 
				
			||||||
  user: User
 | 
					  user: User
 | 
				
			||||||
  field: 'bio' | 'website' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
 | 
					  field: 'bio' | 'website' | 'twitterHandle' | 'discordHandle'
 | 
				
			||||||
  label: string
 | 
					  label: string
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { user, field, label } = props
 | 
					  const { user, field, label } = props
 | 
				
			||||||
| 
						 | 
					@ -43,16 +44,15 @@ function EditUserField(props: {
 | 
				
			||||||
      <label className="label">{label}</label>
 | 
					      <label className="label">{label}</label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {field === 'bio' ? (
 | 
					      {field === 'bio' ? (
 | 
				
			||||||
        <Textarea
 | 
					        <ExpandingInput
 | 
				
			||||||
          className="textarea textarea-bordered w-full resize-none"
 | 
					          className="w-full"
 | 
				
			||||||
          value={value}
 | 
					          value={value}
 | 
				
			||||||
          onChange={(e) => setValue(e.target.value)}
 | 
					          onChange={(e) => setValue(e.target.value)}
 | 
				
			||||||
          onBlur={updateField}
 | 
					          onBlur={updateField}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      ) : (
 | 
					      ) : (
 | 
				
			||||||
        <input
 | 
					        <Input
 | 
				
			||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          className="input input-bordered"
 | 
					 | 
				
			||||||
          value={value}
 | 
					          value={value}
 | 
				
			||||||
          onChange={(e) => setValue(e.target.value || '')}
 | 
					          onChange={(e) => setValue(e.target.value || '')}
 | 
				
			||||||
          onBlur={updateField}
 | 
					          onBlur={updateField}
 | 
				
			||||||
| 
						 | 
					@ -152,10 +152,9 @@ export default function ProfilePage(props: {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <label className="label">Display name</label>
 | 
					            <label className="label">Display name</label>
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              type="text"
 | 
					              type="text"
 | 
				
			||||||
              placeholder="Display name"
 | 
					              placeholder="Display name"
 | 
				
			||||||
              className="input input-bordered"
 | 
					 | 
				
			||||||
              value={name}
 | 
					              value={name}
 | 
				
			||||||
              onChange={(e) => setName(e.target.value || '')}
 | 
					              onChange={(e) => setName(e.target.value || '')}
 | 
				
			||||||
              onBlur={updateDisplayName}
 | 
					              onBlur={updateDisplayName}
 | 
				
			||||||
| 
						 | 
					@ -164,10 +163,9 @@ export default function ProfilePage(props: {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <label className="label">Username</label>
 | 
					            <label className="label">Username</label>
 | 
				
			||||||
            <input
 | 
					            <Input
 | 
				
			||||||
              type="text"
 | 
					              type="text"
 | 
				
			||||||
              placeholder="Username"
 | 
					              placeholder="Username"
 | 
				
			||||||
              className="input input-bordered"
 | 
					 | 
				
			||||||
              value={username}
 | 
					              value={username}
 | 
				
			||||||
              onChange={(e) => setUsername(e.target.value || '')}
 | 
					              onChange={(e) => setUsername(e.target.value || '')}
 | 
				
			||||||
              onBlur={updateUsername}
 | 
					              onBlur={updateUsername}
 | 
				
			||||||
| 
						 | 
					@ -199,10 +197,9 @@ export default function ProfilePage(props: {
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <label className="label">API key</label>
 | 
					            <label className="label">API key</label>
 | 
				
			||||||
            <div className="input-group w-full">
 | 
					            <div className="input-group w-full">
 | 
				
			||||||
              <input
 | 
					              <Input
 | 
				
			||||||
                type="text"
 | 
					                type="text"
 | 
				
			||||||
                placeholder="Click refresh to generate key"
 | 
					                placeholder="Click refresh to generate key"
 | 
				
			||||||
                className="input input-bordered w-full"
 | 
					 | 
				
			||||||
                value={apiKey}
 | 
					                value={apiKey}
 | 
				
			||||||
                readOnly
 | 
					                readOnly
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
 | 
				
			||||||
import { InfoBox } from 'web/components/info-box'
 | 
					import { InfoBox } from 'web/components/info-box'
 | 
				
			||||||
import { QRCode } from 'web/components/qr-code'
 | 
					import { QRCode } from 'web/components/qr-code'
 | 
				
			||||||
import { REFERRAL_AMOUNT } from 'common/economy'
 | 
					import { REFERRAL_AMOUNT } from 'common/economy'
 | 
				
			||||||
 | 
					import { formatMoney } from 'common/util/format'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getServerSideProps = redirectIfLoggedOut('/')
 | 
					export const getServerSideProps = redirectIfLoggedOut('/')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,15 +24,17 @@ export default function ReferralsPage() {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Page>
 | 
					    <Page>
 | 
				
			||||||
      <SEO
 | 
					      <SEO
 | 
				
			||||||
        title="Referrals"
 | 
					        title="Refer a friend"
 | 
				
			||||||
        description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
 | 
					        description={`Invite new users to Manifold and get ${formatMoney(
 | 
				
			||||||
 | 
					          REFERRAL_AMOUNT
 | 
				
			||||||
 | 
					        )} if they
 | 
				
			||||||
            sign up!`}
 | 
					            sign up!`}
 | 
				
			||||||
        url="/referrals"
 | 
					        url="/referrals"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Col className="items-center">
 | 
					      <Col className="items-center">
 | 
				
			||||||
        <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
 | 
					        <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
 | 
				
			||||||
          <Title className="!mt-0" text="Referrals" />
 | 
					          <Title className="!mt-0" text="Refer a friend" />
 | 
				
			||||||
          <img
 | 
					          <img
 | 
				
			||||||
            className="mb-6 block -scale-x-100 self-center"
 | 
					            className="mb-6 block -scale-x-100 self-center"
 | 
				
			||||||
            src="/logo-flapping-with-money.gif"
 | 
					            src="/logo-flapping-with-money.gif"
 | 
				
			||||||
| 
						 | 
					@ -40,8 +43,8 @@ export default function ReferralsPage() {
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div className={'mb-4'}>
 | 
					          <div className={'mb-4'}>
 | 
				
			||||||
            Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
 | 
					            Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '}
 | 
				
			||||||
            sign up!
 | 
					            if they sign up!
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <CopyLinkButton
 | 
					          <CopyLinkButton
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										28
									
								
								web/public/award.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								web/public/award.svg
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
 | 
				
			||||||
 | 
					  <g id="color">
 | 
				
			||||||
 | 
					    <polyline fill="#92d3f5" stroke="#92d3f5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="54.9988,4.0221 43,16.0208 36,16.0208 30.9584,10.9792 37.9207,4.0169 54.9988,4.0169"/>
 | 
				
			||||||
 | 
					    <polyline fill="#ea5a47" stroke="#ea5a47" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="23.9831,4.0039 36,16.0208 29,16.0208 16.9675,3.9883 23.9831,3.9883"/>
 | 
				
			||||||
 | 
					    <polyline fill="#fcea2b" stroke="none" points="28,22.4271 28,17 44,17 44,22.4271"/>
 | 
				
			||||||
 | 
					    <circle cx="36" cy="45.0208" r="23" fill="#fcea2b" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <polygon fill="#f1b31c" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/>
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					  <g id="hair"/>
 | 
				
			||||||
 | 
					  <g id="skin"/>
 | 
				
			||||||
 | 
					  <g id="skin-shadow"/>
 | 
				
			||||||
 | 
					  <g id="line">
 | 
				
			||||||
 | 
					    <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="29" x2="29" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="43" x2="43" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="29" x2="43" y1="16.0208" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="25.9896" x2="16.9675" y1="13.0104" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="31.9896" x2="23.9831" y1="12.0104" y2="4.0039" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="34" x2="37.9207" y1="8" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="46" x2="54.9988" y1="13" y2="4.0221" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="16.9675" x2="23.9831" y1="3.9883" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <line x1="37.9207" x2="54.9988" y1="4.0169" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
 | 
				
			||||||
 | 
					    <polygon fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/>
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 3.4 KiB  | 
		Loading…
	
		Reference in New Issue
	
	Block a user