Award badges for market creation, betting streaks, proven correct (#891)
* Award badges for market creation, betting streaks, proven correct * Styling * Add minimum unique bettors for proven correct * Add name, refactor * Add notifications for badge awards * Correct styling * Need at least 3 unique bettors for market maker badge * Lint * Switch to badges_awarded * Don't include n/a resolutions in market creator badge * Add badges by rarities to profile * Show badges on profile, soon on market page * Add achievements to new user * Backfill all users badges
This commit is contained in:
		
							parent
							
								
									cdc64c6475
								
							
						
					
					
						commit
						f26ba1c4a2
					
				
							
								
								
									
										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 | ||||
| } | ||||
|  | @ -4,7 +4,7 @@ export type Notification = { | |||
|   id: string | ||||
|   userId: string | ||||
|   reasonText?: string | ||||
|   reason?: notification_reason_types | ||||
|   reason?: notification_reason_types | notification_preference | ||||
|   createdTime: number | ||||
|   viewTime?: number | ||||
|   isSeen: boolean | ||||
|  | @ -46,6 +46,7 @@ export type notification_source_types = | |||
|   | 'loan' | ||||
|   | 'like' | ||||
|   | 'tip_and_like' | ||||
|   | 'badge' | ||||
| 
 | ||||
| export type notification_source_update_types = | ||||
|   | 'created' | ||||
|  | @ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { | |||
|     simple: `Only on markets 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: { | ||||
|     simple: 'Opt out of all notifications (excludes when your markets close)', | ||||
|     detailed: | ||||
|  |  | |||
|  | @ -1,8 +1,9 @@ | |||
| import { groupBy, sumBy, mapValues } from 'lodash' | ||||
| import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' | ||||
| 
 | ||||
| import { Bet } from './bet' | ||||
| import { getContractBetMetrics } from './calculate' | ||||
| import { getContractBetMetrics, resolvedPayout } from './calculate' | ||||
| import { Contract } from './contract' | ||||
| import { ContractComment } from './comment' | ||||
| 
 | ||||
| export function scoreCreators(contracts: Contract[]) { | ||||
|   const creatorScore = mapValues( | ||||
|  | @ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) { | |||
| } | ||||
| 
 | ||||
| export function scoreUsersByContract(contract: Contract, bets: Bet[]) { | ||||
|   const betsByUser = groupBy(bets, bet => bet.userId) | ||||
|   return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) | ||||
|   const betsByUser = groupBy(bets, (bet) => bet.userId) | ||||
|   return mapValues( | ||||
|     betsByUser, | ||||
|     (bets) => getContractBetMetrics(contract, bets).profit | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function addUserScores( | ||||
|  | @ -43,3 +47,47 @@ export function addUserScores( | |||
|     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[] | ||||
|   onboarding_flow: notification_destination_types[] | ||||
|   thank_you_for_purchases: notification_destination_types[] | ||||
| 
 | ||||
|   badges_awarded: notification_destination_types[] | ||||
|   opt_out_all: notification_destination_types[] | ||||
|   // 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), | ||||
| 
 | ||||
|     opt_out_all: [], | ||||
|     badges_awarded: constructPref(true, false), | ||||
|   } | ||||
|   return defaults | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| 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 = { | ||||
|   id: string | ||||
|  | @ -51,6 +52,18 @@ export type User = { | |||
|   hasSeenContractFollowModal?: boolean | ||||
|   freeMarketsCreated?: number | ||||
|   isBannedFromPosting?: boolean | ||||
| 
 | ||||
|   achievements: { | ||||
|     provenCorrect?: { | ||||
|       badges: ProvenCorrectBadge[] | ||||
|     } | ||||
|     marketCreator?: { | ||||
|       badges: MarketCreatorBadge[] | ||||
|     } | ||||
|     streaker?: { | ||||
|       badges: StreakerBadge[] | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export type PrivateUser = { | ||||
|  | @ -81,7 +94,8 @@ export type PortfolioMetrics = { | |||
|   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' | ||||
| 
 | ||||
| // TODO: remove. Hardcoding the strings would be better.
 | ||||
|  |  | |||
|  | @ -6,7 +6,12 @@ import { | |||
|   Notification, | ||||
|   notification_reason_types, | ||||
| } 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 { getPrivateUser, getValues } from './utils' | ||||
| import { Comment } from '../../common/comment' | ||||
|  | @ -30,6 +35,7 @@ import { | |||
| import { filterDefined } from '../../common/util/array' | ||||
| import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' | ||||
| import { ContractFollow } from '../../common/follow' | ||||
| import { Badge } from 'common/badge' | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| type recipients_to_reason_texts = { | ||||
|  | @ -1087,6 +1093,43 @@ export const createBountyNotification = async ( | |||
|     sourceTitle: contract.question, | ||||
|   } | ||||
|   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, | ||||
|     shouldShowWelcome: true, | ||||
|     fractionResolvedCorrectly: 1, | ||||
|     achievements: {}, | ||||
|   } | ||||
| 
 | ||||
|   await firestore.collection('users').doc(auth.uid).create(user) | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import { | |||
|   revalidateStaticProps, | ||||
| } from './utils' | ||||
| import { | ||||
|   createBadgeAwardedNotification, | ||||
|   createBetFillNotification, | ||||
|   createBettingStreakBonusNotification, | ||||
|   createUniqueBettorBonusNotification, | ||||
|  | @ -33,6 +34,10 @@ import { APIError } from '../../common/api' | |||
| import { User } from '../../common/user' | ||||
| import { DAY_MS } from '../../common/util/time' | ||||
| import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' | ||||
| import { | ||||
|   StreakerBadge, | ||||
|   streakerBadgeRarityThresholds, | ||||
| } from '../../common/badge' | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() | ||||
|  | @ -143,7 +148,7 @@ const updateBettingStreak = async ( | |||
|     log('message:', result.message) | ||||
|     return | ||||
|   } | ||||
|   if (result.txn) | ||||
|   if (result.txn) { | ||||
|     await createBettingStreakBonusNotification( | ||||
|       user, | ||||
|       result.txn.id, | ||||
|  | @ -153,6 +158,8 @@ const updateBettingStreak = async ( | |||
|       newBettingStreak, | ||||
|       eventId | ||||
|     ) | ||||
|     await handleBettingStreakBadgeAward(user, newBettingStreak) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const updateUniqueBettorsAndGiveCreatorBonus = async ( | ||||
|  | @ -296,3 +303,39 @@ const notifyFills = async ( | |||
| const currentDateBettingStreakResetTime = () => { | ||||
|   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', | ||||
|       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 { getUser } from './utils' | ||||
| import { createNewContractNotification } from './create-notification' | ||||
| import { getUser, getValues } from './utils' | ||||
| import { | ||||
|   createBadgeAwardedNotification, | ||||
|   createNewContractNotification, | ||||
| } from './create-notification' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { parseMentions, richTextToString } from '../../common/util/parse' | ||||
| import { JSONContent } from '@tiptap/core' | ||||
| 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 | ||||
|   .runWith({ secrets: ['MAILGUN_KEY'] }) | ||||
|  | @ -28,4 +37,43 @@ export const onCreateContract = functions | |||
|       richTextToString(desc), | ||||
|       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 { getUser } from './utils' | ||||
| import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' | ||||
| import { getUser, getValues } from './utils' | ||||
| import { | ||||
|   createBadgeAwardedNotification, | ||||
|   createCommentOrAnswerOrUpdatedContractNotification, | ||||
| } from './create-notification' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { GroupContractDoc } from '../../common/group' | ||||
| import { Bet } from '../../common/bet' | ||||
| 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 | ||||
|   .document('contracts/{contractId}') | ||||
|  | @ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore | |||
| 
 | ||||
|     if (!previousContract.isResolved && contract.isResolved) { | ||||
|       // No need to notify users of resolution, that's handled in resolve-market
 | ||||
|       return | ||||
|       return await handleResolvedContract(contract) | ||||
|     } else if (previousContract.groupSlugs !== contract.groupSlugs) { | ||||
|       await handleContractGroupUpdated(previousContract, contract) | ||||
|     } 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( | ||||
|   previousContract: Contract, | ||||
|   contract: Contract, | ||||
|  |  | |||
|  | @ -1,29 +1,24 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| 
 | ||||
| import { initAdmin } from './script-init' | ||||
| import { filterDefined } from 'common/lib/util/array' | ||||
| import { getPrivateUser } from '../utils' | ||||
| import { getAllPrivateUsers } from 'functions/src/utils' | ||||
| initAdmin() | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| async function main() { | ||||
|   // const privateUsers = await getAllPrivateUsers()
 | ||||
|   const privateUsers = filterDefined([ | ||||
|     await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), | ||||
|   ]) | ||||
|   const privateUsers = await getAllPrivateUsers() | ||||
|   await Promise.all( | ||||
|     privateUsers.map((privateUser) => { | ||||
|       if (!privateUser.id) return Promise.resolve() | ||||
|       if (privateUser.notificationPreferences.opt_out_all === undefined) { | ||||
|         console.log('updating opt out all', privateUser.id) | ||||
|       if (privateUser.notificationPreferences.badges_awarded === undefined) { | ||||
|         return firestore | ||||
|           .collection('private-users') | ||||
|           .doc(privateUser.id) | ||||
|           .update({ | ||||
|             notificationPreferences: { | ||||
|               ...privateUser.notificationPreferences, | ||||
|               opt_out_all: [], | ||||
|               badges_awarded: ['browser'], | ||||
|             }, | ||||
|           }) | ||||
|       } | ||||
|  |  | |||
							
								
								
									
										101
									
								
								functions/src/scripts/backfill-badges.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								functions/src/scripts/backfill-badges.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,101 @@ | |||
| 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) => { | ||||
|       // console.log('Added achievements to user', user.id)
 | ||||
|       if (!user.id) return | ||||
|       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) | ||||
|       // 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 | ||||
| } | ||||
|  | @ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => { | |||
|   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) => { | ||||
|   const firestore = admin.firestore() | ||||
|   const snap = await firestore | ||||
|  |  | |||
							
								
								
									
										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 { NextRouter } from 'next/router' | ||||
| 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' | ||||
| 
 | ||||
| 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 | ||||
|   router?: NextRouter | ||||
| }) { | ||||
|   const { user, router } = props | ||||
|   const [showBadgesModal, setShowBadgesModal] = useState(false) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!router) return | ||||
|     const showBadgesModal = router.query['show'] === 'badges' | ||||
|     setShowBadgesModal(showBadgesModal) | ||||
|   }, [router]) | ||||
|   // 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> | ||||
|   ) | ||||
| } | ||||
|  | @ -154,8 +154,8 @@ export function MarketSubheader(props: { | |||
|   const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract | ||||
|   const { resolvedDate } = contractMetrics(contract) | ||||
|   const user = useUser() | ||||
|   const correctResolutionPercentage = | ||||
|     useUserById(creatorId)?.fractionResolvedCorrectly | ||||
|   const creator = useUserById(creatorId) | ||||
|   const correctResolutionPercentage = creator?.fractionResolvedCorrectly | ||||
|   const isCreator = user?.id === creatorId | ||||
|   const isMobile = useIsMobile() | ||||
|   return ( | ||||
|  | @ -178,12 +178,15 @@ export function MarketSubheader(props: { | |||
|           {disabled ? ( | ||||
|             creatorName | ||||
|           ) : ( | ||||
|             <UserLink | ||||
|               className="my-auto whitespace-nowrap" | ||||
|               name={creatorName} | ||||
|               username={creatorUsername} | ||||
|               short={isMobile} | ||||
|             /> | ||||
|             <Row className={'gap-2'}> | ||||
|               <UserLink | ||||
|                 className="my-auto whitespace-nowrap" | ||||
|                 name={creatorName} | ||||
|                 username={creatorUsername} | ||||
|                 short={isMobile} | ||||
|               /> | ||||
|               {/*<BadgeDisplay user={creator} />*/} | ||||
|             </Row> | ||||
|           )} | ||||
|           {correctResolutionPercentage != null && | ||||
|             correctResolutionPercentage < BAD_CREATOR_THRESHOLD && ( | ||||
|  |  | |||
|  | @ -2,15 +2,17 @@ import { Bet } from 'common/bet' | |||
| import { resolvedPayout } from 'common/calculate' | ||||
| import { Contract } from 'common/contract' | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' | ||||
| import { memo } from 'react' | ||||
| import { useComments } from 'web/hooks/use-comments' | ||||
| 
 | ||||
| import { groupBy, mapValues, sumBy } from 'lodash' | ||||
| import { FeedBet } from '../feed/feed-bets' | ||||
| import { FeedComment } from '../feed/feed-comments' | ||||
| import { Spacer } from '../layout/spacer' | ||||
| import { Leaderboard } from '../leaderboard' | ||||
| import { Title } from '../title' | ||||
| 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: { | ||||
|   contract: Contract | ||||
|  | @ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: { | |||
|   ) : null | ||||
| }) | ||||
| 
 | ||||
| export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) { | ||||
|   const { contract, bets } = props | ||||
|   // todo: this stuff should be calced in DB at resolve time
 | ||||
|   const comments = useComments(contract.id) | ||||
|   const betsById = keyBy(bets, 'id') | ||||
| 
 | ||||
|   // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
 | ||||
|   // 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 comment with the highest profit
 | ||||
|   const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0] | ||||
| 
 | ||||
| export function ContractTopTrades(props: { | ||||
|   contract: Contract | ||||
|   bets: Bet[] | ||||
|   comments: ContractComment[] | ||||
| }) { | ||||
|   const { contract, bets, comments } = props | ||||
|   const { | ||||
|     topBetId, | ||||
|     topBettor, | ||||
|     profitById, | ||||
|     betsById, | ||||
|     topCommentId, | ||||
|     commentsById, | ||||
|     topCommentBetId, | ||||
|   } = scoreCommentorsAndBettors(contract, bets, comments) | ||||
|   return ( | ||||
|     <div className="mt-12 max-w-sm"> | ||||
|       {topComment && profitById[topComment.id] > 0 && ( | ||||
|       {topCommentBetId && profitById[topCommentBetId] > 0 && ( | ||||
|         <> | ||||
|           <Title text="💬 Proven correct" className="!mt-0" /> | ||||
|           <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> | ||||
|             <FeedComment contract={contract} comment={topComment} /> | ||||
|             <FeedComment | ||||
|               contract={contract} | ||||
|               comment={commentsById[topCommentId]} | ||||
|             /> | ||||
|           </div> | ||||
|           <Spacer h={16} /> | ||||
|         </> | ||||
|       )} | ||||
| 
 | ||||
|       {/* 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" /> | ||||
|           <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> | ||||
|  |  | |||
|  | @ -131,6 +131,7 @@ export function NotificationSettings(props: { | |||
|       'betting_streaks', | ||||
|       'referral_bonuses', | ||||
|       'unique_bettors_on_your_contract', | ||||
|       'badges_awarded', | ||||
|     ], | ||||
|   } | ||||
|   const otherBalances: SectionData = { | ||||
|  |  | |||
							
								
								
									
										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: { | ||||
|   isOpen: boolean | ||||
|   setOpen: (open: boolean) => void | ||||
|   currentUser?: User | null | ||||
|   currentUser: User | null | undefined | ||||
| }) { | ||||
|   const { isOpen, setOpen, currentUser } = props | ||||
|   const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) | ||||
|  |  | |||
|  | @ -31,14 +31,12 @@ import { UserFollowButton } from './follow-button' | |||
| import { GroupsButton } from 'web/components/groups/groups-button' | ||||
| import { PortfolioValueSection } from './portfolio/portfolio-value-section' | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { | ||||
|   BettingStreakModal, | ||||
|   hasCompletedStreakToday, | ||||
| } from 'web/components/profile/betting-streak-modal' | ||||
| 
 | ||||
| import { LoansModal } from './profile/loans-modal' | ||||
| import { copyToClipboard } from 'web/lib/util/copy' | ||||
| import { track } from 'web/lib/service/analytics' | ||||
| import { DOMAIN } from 'common/envs/constants' | ||||
| import { BadgeDisplay } from 'web/components/badge-display' | ||||
| 
 | ||||
| export function UserPage(props: { user: User }) { | ||||
|   const { user } = props | ||||
|  | @ -79,6 +77,7 @@ export function UserPage(props: { user: User }) { | |||
|       {showConfetti && ( | ||||
|         <FullscreenConfetti recycle={false} numberOfPieces={300} /> | ||||
|       )} | ||||
| 
 | ||||
|       <Col className="relative"> | ||||
|         <Row className="relative px-4 pt-4"> | ||||
|           <Avatar | ||||
|  | @ -101,9 +100,10 @@ export function UserPage(props: { user: User }) { | |||
|                 <span className="break-anywhere text-lg font-bold sm:text-2xl"> | ||||
|                   {user.name} | ||||
|                 </span> | ||||
|                 <span className="sm:text-md text-greyscale-4 text-sm"> | ||||
|                   @{user.username} | ||||
|                 </span> | ||||
|                 <Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm "> | ||||
|                   <span className={' text-greyscale-4'}>@{user.username}</span> | ||||
|                   <BadgeDisplay user={user} router={router} /> | ||||
|                 </Row> | ||||
|               </Col> | ||||
|               {isCurrentUser && ( | ||||
|                 <ProfilePrivateStats | ||||
|  | @ -278,14 +278,10 @@ export function ProfilePrivateStats(props: { | |||
|   user: User | ||||
|   router: NextRouter | ||||
| }) { | ||||
|   const { currentUser, profit, user, router } = props | ||||
|   const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) | ||||
|   const { profit, user, router } = props | ||||
|   const [showLoansModal, setShowLoansModal] = useState(false) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const showBettingStreak = router.query['show'] === 'betting-streak' | ||||
|     setShowBettingStreakModal(showBettingStreak) | ||||
| 
 | ||||
|     const showLoansModel = router.query['show'] === 'loans' | ||||
|     setShowLoansModal(showLoansModel) | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|  | @ -301,23 +297,6 @@ export function ProfilePrivateStats(props: { | |||
|           </span> | ||||
|           <span className="mx-auto text-xs sm:text-sm">profit</span> | ||||
|         </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 | ||||
|           className={ | ||||
|             '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> | ||||
|         </Col> | ||||
|       </Row> | ||||
|       {BettingStreakModal && ( | ||||
|         <BettingStreakModal | ||||
|           isOpen={showBettingStreakModal} | ||||
|           setOpen={setShowBettingStreakModal} | ||||
|           currentUser={currentUser} | ||||
|         /> | ||||
|       )} | ||||
|       {showLoansModal && ( | ||||
|         <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> | ||||
|       )} | ||||
|  |  | |||
|  | @ -270,7 +270,11 @@ export function ContractPageContent( | |||
|           <> | ||||
|             <div className="grid grid-cols-1 sm:grid-cols-2"> | ||||
|               <ContractLeaderboard contract={contract} bets={bets} /> | ||||
|               <ContractTopTrades contract={contract} bets={bets} /> | ||||
|               <ContractTopTrades | ||||
|                 contract={contract} | ||||
|                 bets={bets} | ||||
|                 comments={comments} | ||||
|               /> | ||||
|             </div> | ||||
|             <Spacer h={12} /> | ||||
|           </> | ||||
|  |  | |||
|  | @ -13,11 +13,7 @@ import { Page } from 'web/components/page' | |||
| import { Title } from 'web/components/title' | ||||
| import { doc, updateDoc } from 'firebase/firestore' | ||||
| import { db } from 'web/lib/firebase/init' | ||||
| import { | ||||
|   MANIFOLD_AVATAR_URL, | ||||
|   MANIFOLD_USERNAME, | ||||
|   PrivateUser, | ||||
| } from 'common/user' | ||||
| import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user' | ||||
| import clsx from 'clsx' | ||||
| import { RelativeTimestamp } from 'web/components/relative-timestamp' | ||||
| import { Linkify } from 'web/components/linkify' | ||||
|  | @ -739,6 +735,24 @@ function NotificationItem(props: { | |||
|         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
 | ||||
| 
 | ||||
|  | @ -812,9 +826,16 @@ function NotificationFrame(props: { | |||
|   subtitle: string | ||||
|   children: React.ReactNode | ||||
|   isChildOfGroup?: boolean | ||||
|   showUserName?: boolean | ||||
| }) { | ||||
|   const { notification, isChildOfGroup, highlighted, subtitle, children } = | ||||
|     props | ||||
|   const { | ||||
|     notification, | ||||
|     isChildOfGroup, | ||||
|     highlighted, | ||||
|     subtitle, | ||||
|     children, | ||||
|     showUserName, | ||||
|   } = props | ||||
|   const { | ||||
|     sourceType, | ||||
|     sourceUserName, | ||||
|  | @ -825,7 +846,7 @@ function NotificationFrame(props: { | |||
|     sourceUserUsername, | ||||
|     sourceText, | ||||
|   } = notification | ||||
|   const questionNeedsResolution = sourceUpdateType == 'closed' | ||||
| 
 | ||||
|   const { width } = useWindowSize() | ||||
|   const isMobile = (width ?? 0) < 600 | ||||
|   return ( | ||||
|  | @ -855,16 +876,10 @@ function NotificationFrame(props: { | |||
|         /> | ||||
|         <Row className={'items-center text-gray-500 sm:justify-start'}> | ||||
|           <Avatar | ||||
|             avatarUrl={ | ||||
|               questionNeedsResolution | ||||
|                 ? MANIFOLD_AVATAR_URL | ||||
|                 : sourceUserAvatarUrl | ||||
|             } | ||||
|             avatarUrl={sourceUserAvatarUrl} | ||||
|             size={'sm'} | ||||
|             className={'z-10 mr-2'} | ||||
|             username={ | ||||
|               questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername | ||||
|             } | ||||
|             username={sourceUserUsername} | ||||
|           /> | ||||
|           <div className={'flex w-full flex-row pl-1 sm:pl-0'}> | ||||
|             <div | ||||
|  | @ -873,12 +888,14 @@ function NotificationFrame(props: { | |||
|               } | ||||
|             > | ||||
|               <div> | ||||
|                 <UserLink | ||||
|                   name={sourceUserName || ''} | ||||
|                   username={sourceUserUsername || ''} | ||||
|                   className={'relative mr-1 flex-shrink-0'} | ||||
|                   short={isMobile} | ||||
|                 /> | ||||
|                 {showUserName && ( | ||||
|                   <UserLink | ||||
|                     name={sourceUserName || ''} | ||||
|                     username={sourceUserUsername || ''} | ||||
|                     className={'relative mr-1 flex-shrink-0'} | ||||
|                     short={isMobile} | ||||
|                   /> | ||||
|                 )} | ||||
|                 {subtitle} | ||||
|                 {isChildOfGroup ? ( | ||||
|                   <RelativeTimestamp time={notification.createdTime} /> | ||||
|  | @ -967,6 +984,65 @@ 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} | ||||
|       showUserName={false} | ||||
|     > | ||||
|       <Row> | ||||
|         <span>{sourceText} 🎉</span> | ||||
|       </Row> | ||||
|     </NotificationFrame> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function ContractResolvedNotification(props: { | ||||
|   notification: Notification | ||||
|   highlighted: boolean | ||||
|  | @ -1137,6 +1213,11 @@ function getSourceUrl(notification: Notification) { | |||
|       sourceId ?? '', | ||||
|       sourceType | ||||
|     )}` | ||||
|   else if (sourceSlug) | ||||
|     return `/${sourceSlug}#${getSourceIdForLinkComponent( | ||||
|       sourceId ?? '', | ||||
|       sourceType | ||||
|     )}` | ||||
| } | ||||
| 
 | ||||
| function getSourceIdForLinkComponent( | ||||
|  | @ -1236,7 +1317,6 @@ function getReasonForShowingNotification( | |||
|           reasonText = justSummary ? 'asked the question' : 'asked' | ||||
|         else if (sourceUpdateType === 'resolved') | ||||
|           reasonText = justSummary ? `resolved the question` : `resolved` | ||||
|         else if (sourceUpdateType === 'closed') reasonText = `Please resolve` | ||||
|         else reasonText = justSummary ? 'updated the question' : `updated` | ||||
|         break | ||||
|       case 'answer': | ||||
|  |  | |||
							
								
								
									
										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