Award badges for market creation, betting streaks, proven correct
This commit is contained in:
parent
68f2277def
commit
94bb9370ae
121
common/badge.ts
Normal file
121
common/badge.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import { User } from './user'
|
||||||
|
|
||||||
|
export type Achievement = {
|
||||||
|
totalBadges: number
|
||||||
|
badges: Badge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Badge = {
|
||||||
|
type: BadgeTypes
|
||||||
|
createdTime: number
|
||||||
|
data: { [key: string]: any }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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, 25, 100]
|
||||||
|
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 marketMakerBadgeRarityThresholds = [1, 25, 150]
|
||||||
|
const calculateMarketMakerBadgeRarity = (badge: MarketCreatorBadge) => {
|
||||||
|
const { totalContractsCreated } = badge.data
|
||||||
|
const thresholdArray = marketMakerBadgeRarityThresholds
|
||||||
|
let i = thresholdArray.length - 1
|
||||||
|
while (i >= 0) {
|
||||||
|
if (totalContractsCreated == thresholdArray[i]) {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export type rarities = 'common' | 'bronze' | 'silver' | 'gold'
|
||||||
|
|
||||||
|
const rarityRanks: { [key: number]: rarities } = {
|
||||||
|
0: 'common',
|
||||||
|
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[
|
||||||
|
calculateMarketMakerBadgeRarity(badge as MarketCreatorBadge)
|
||||||
|
]
|
||||||
|
case 'STREAKER':
|
||||||
|
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
|
||||||
|
default:
|
||||||
|
return rarityRanks[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateTotalUsersBadges = (user: User) => {
|
||||||
|
const { achievements } = user
|
||||||
|
if (!achievements) return 0
|
||||||
|
return (
|
||||||
|
(achievements.marketCreator?.totalBadges ?? 0) +
|
||||||
|
(achievements.provenCorrect?.totalBadges ?? 0) +
|
||||||
|
(achievements.streaker?.totalBadges ?? 0)
|
||||||
|
)
|
||||||
|
}
|
|
@ -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,45 @@ 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
|
||||||
|
|
||||||
|
return {
|
||||||
|
topCommentId,
|
||||||
|
topBetId,
|
||||||
|
topBettor,
|
||||||
|
profitById,
|
||||||
|
commentsById,
|
||||||
|
betsById,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 { Achievement } from './badge'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -49,6 +50,12 @@ export type User = {
|
||||||
hasSeenContractFollowModal?: boolean
|
hasSeenContractFollowModal?: boolean
|
||||||
freeMarketsCreated?: number
|
freeMarketsCreated?: number
|
||||||
isBannedFromPosting?: boolean
|
isBannedFromPosting?: boolean
|
||||||
|
|
||||||
|
achievements?: {
|
||||||
|
provenCorrect?: Achievement
|
||||||
|
marketCreator?: Achievement
|
||||||
|
streaker?: Achievement
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
|
|
|
@ -28,6 +28,10 @@ import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
||||||
import { addHouseLiquidity } from './add-liquidity'
|
import { addHouseLiquidity } from './add-liquidity'
|
||||||
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()
|
||||||
|
@ -141,6 +145,7 @@ const updateBettingStreak = async (
|
||||||
newBettingStreak,
|
newBettingStreak,
|
||||||
eventId
|
eventId
|
||||||
)
|
)
|
||||||
|
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
@ -276,3 +281,38 @@ 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
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
|
||||||
|
|
||||||
|
if (newBettingStreak in streakerBadgeRarityThresholds) {
|
||||||
|
const badge = {
|
||||||
|
type: 'STREAKER',
|
||||||
|
data: {
|
||||||
|
totalBettingStreak: newBettingStreak,
|
||||||
|
},
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as StreakerBadge
|
||||||
|
// update user
|
||||||
|
await firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.update({
|
||||||
|
achievements: {
|
||||||
|
...user.achievements,
|
||||||
|
streaker: {
|
||||||
|
totalBadges: (user.achievements?.streaker?.totalBadges ?? 0) + 1,
|
||||||
|
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
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 { 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,
|
||||||
|
marketMakerBadgeRarityThresholds,
|
||||||
|
} from '../../common/badge'
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -28,4 +34,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)
|
||||||
|
)
|
||||||
|
if (contracts.length in marketMakerBadgeRarityThresholds) {
|
||||||
|
const badge = {
|
||||||
|
type: '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: {
|
||||||
|
totalBadges:
|
||||||
|
(contractCreator.achievements?.marketCreator?.totalBadges ?? 0) +
|
||||||
|
1,
|
||||||
|
badges: [
|
||||||
|
...(contractCreator.achievements?.marketCreator?.badges ?? []),
|
||||||
|
badge,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
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 { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
import { Bet } from '../../common/bet'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { ContractComment } from '../../common/comment'
|
||||||
|
import { scoreCommentorsAndBettors } from '../../common/scoring'
|
||||||
|
import { ProvenCorrectBadge } from '../../common/badge'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
.document('contracts/{contractId}')
|
.document('contracts/{contractId}')
|
||||||
|
@ -14,8 +19,9 @@ export const onUpdateContract = functions.firestore
|
||||||
|
|
||||||
const previousValue = change.before.data() as Contract
|
const previousValue = change.before.data() as Contract
|
||||||
|
|
||||||
// Resolution is handled in resolve-market.ts
|
// Notifications for market resolution are also handled in resolve-market.ts
|
||||||
if (!previousValue.isResolved && contract.isResolved) return
|
if (!previousValue.isResolved && contract.isResolved)
|
||||||
|
return await handleResolvedContract(contract)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
|
@ -42,3 +48,55 @@ export const onUpdateContract = functions.firestore
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function handleResolvedContract(contract: Contract) {
|
||||||
|
// 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 } =
|
||||||
|
scoreCommentorsAndBettors(contract, bets, comments)
|
||||||
|
if (topCommentId && profitById[topCommentId] > 0) {
|
||||||
|
// award proven correct badge to user
|
||||||
|
const comment = commentsById[topCommentId]
|
||||||
|
const bet = betsById[topCommentId]
|
||||||
|
|
||||||
|
const user = await getUser(comment.userId)
|
||||||
|
if (!user) return
|
||||||
|
const newProvenCorrectBadge = {
|
||||||
|
createdTime: Date.now(),
|
||||||
|
type: '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: {
|
||||||
|
totalBadges:
|
||||||
|
(user.achievements?.provenCorrect?.totalBadges ?? 0) + 1,
|
||||||
|
badges: [
|
||||||
|
...(user.achievements?.provenCorrect?.badges ?? []),
|
||||||
|
newProvenCorrectBadge,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment'
|
||||||
import { resolvedPayout } from 'common/calculate'
|
import { resolvedPayout } from 'common/calculate'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
import { groupBy, mapValues, sumBy, sortBy } from 'lodash'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { listUsers, User } from 'web/lib/firebase/users'
|
import { listUsers, User } from 'web/lib/firebase/users'
|
||||||
|
@ -13,6 +13,7 @@ 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'
|
||||||
|
|
||||||
export function ContractLeaderboard(props: {
|
export function ContractLeaderboard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -69,33 +70,14 @@ export function ContractTopTrades(props: {
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, comments, tips } = props
|
const { contract, bets, comments, tips } = props
|
||||||
const commentsById = keyBy(comments, 'id')
|
const {
|
||||||
const betsById = keyBy(bets, 'id')
|
topCommentId,
|
||||||
|
topBetId,
|
||||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
topBettor,
|
||||||
// Otherwise, we record the profit at resolution time
|
profitById,
|
||||||
const profitById: Record<string, number> = {}
|
commentsById,
|
||||||
for (const bet of bets) {
|
betsById,
|
||||||
if (bet.sale) {
|
} = scoreCommentorsAndBettors(contract, bets, comments)
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 max-w-sm">
|
<div className="mt-12 max-w-sm">
|
||||||
{topCommentId && profitById[topCommentId] > 0 && (
|
{topCommentId && profitById[topCommentId] > 0 && (
|
||||||
|
|
217
web/components/profile/badges-modal.tsx
Normal file
217
web/components/profile/badges-modal.tsx
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { 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'
|
||||||
|
const goldClassName = 'text-amber-400'
|
||||||
|
const silverClassName = 'text-gray-500'
|
||||||
|
const bronzeClassName = 'text-amber-900'
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) {
|
||||||
|
const { badge, rarity } = props
|
||||||
|
const { totalBettingStreak } = badge.data
|
||||||
|
return (
|
||||||
|
<Col className={'text-center'}>
|
||||||
|
<Medal rarity={rarity} />
|
||||||
|
<Tooltip
|
||||||
|
text={`Make predictions ${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={'text-center'}>
|
||||||
|
<Medal rarity={rarity} />
|
||||||
|
<Tooltip
|
||||||
|
text={`Make ${totalContractsCreated} market${
|
||||||
|
totalContractsCreated > 1 ? 's' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rarity === 'gold'
|
||||||
|
? goldClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: bronzeClassName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Market Maker
|
||||||
|
</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)
|
||||||
|
|
|
@ -37,6 +37,8 @@ import { LoansModal } from './profile/loans-modal'
|
||||||
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||||
import { PAST_BETS } from 'common/user'
|
import { PAST_BETS } from 'common/user'
|
||||||
import { capitalize } from 'lodash'
|
import { capitalize } from 'lodash'
|
||||||
|
import { BadgesModal } from 'web/components/profile/badges-modal'
|
||||||
|
import { calculateTotalUsersBadges } from 'common/badge'
|
||||||
|
|
||||||
export function UserPage(props: { user: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
|
@ -46,6 +48,7 @@ export function UserPage(props: { user: User }) {
|
||||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||||
|
const [showBadgesModal, setShowBadgesModal] = useState(false)
|
||||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -90,9 +93,12 @@ export function UserPage(props: { user: User }) {
|
||||||
setOpen={setShowBettingStreakModal}
|
setOpen={setShowBettingStreakModal}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
{showLoansModal && (
|
|
||||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||||
)}
|
<BadgesModal
|
||||||
|
isOpen={showBadgesModal}
|
||||||
|
setOpen={setShowBadgesModal}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
{/* Banner image up top, with an circle avatar overlaid */}
|
{/* Banner image up top, with an circle avatar overlaid */}
|
||||||
<div
|
<div
|
||||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||||
|
@ -167,6 +173,13 @@ export function UserPage(props: { user: User }) {
|
||||||
</span>
|
</span>
|
||||||
<span>next loan</span>
|
<span>next loan</span>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col
|
||||||
|
className={clsx('cursor-pointer items-center text-gray-500')}
|
||||||
|
onClick={() => setShowBadgesModal(true)}
|
||||||
|
>
|
||||||
|
<span>🏅 {calculateTotalUsersBadges(user)}</span>
|
||||||
|
<span>badges</span>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user