betting streaks (#777)
* Parse notif, show betting streaks modal, schedule function * Ignore streaks of 0 * Pass notifyFills the contract * Turn 9am into a constant * Lint * Up streak reward, fix timing logic * Change wording
This commit is contained in:
		
							parent
							
								
									4f3202f90b
								
							
						
					
					
						commit
						00c9fa61c3
					
				|  | @ -38,6 +38,7 @@ export type notification_source_types = | ||||||
|   | 'user' |   | 'user' | ||||||
|   | 'bonus' |   | 'bonus' | ||||||
|   | 'challenge' |   | 'challenge' | ||||||
|  |   | 'betting_streak_bonus' | ||||||
| 
 | 
 | ||||||
| export type notification_source_update_types = | export type notification_source_update_types = | ||||||
|   | 'created' |   | 'created' | ||||||
|  | @ -66,3 +67,4 @@ export type notification_reason_types = | ||||||
|   | 'bet_fill' |   | 'bet_fill' | ||||||
|   | 'user_joined_from_your_group_invite' |   | 'user_joined_from_your_group_invite' | ||||||
|   | 'challenge_accepted' |   | 'challenge_accepted' | ||||||
|  |   | 'betting_streak_incremented' | ||||||
|  |  | ||||||
|  | @ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005 | ||||||
| export const NUMERIC_GRAPH_COLOR = '#5fa5f9' | export const NUMERIC_GRAPH_COLOR = '#5fa5f9' | ||||||
| export const NUMERIC_TEXT_COLOR = 'text-blue-500' | export const NUMERIC_TEXT_COLOR = 'text-blue-500' | ||||||
| export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 | export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 | ||||||
|  | export const BETTING_STREAK_BONUS_AMOUNT = 5 | ||||||
|  | export const BETTING_STREAK_RESET_HOUR = 9 | ||||||
|  |  | ||||||
|  | @ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | ||||||
|   amount: number |   amount: number | ||||||
|   token: 'M$' // | 'USD' | MarketOutcome
 |   token: 'M$' // | 'USD' | MarketOutcome
 | ||||||
| 
 | 
 | ||||||
|   category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' |   category: | ||||||
|  |     | 'CHARITY' | ||||||
|  |     | 'MANALINK' | ||||||
|  |     | 'TIP' | ||||||
|  |     | 'REFERRAL' | ||||||
|  |     | 'UNIQUE_BETTOR_BONUS' | ||||||
|  |     | 'BETTING_STREAK_BONUS' | ||||||
| 
 | 
 | ||||||
|   // Any extra data
 |   // Any extra data
 | ||||||
|   data?: { [key: string]: any } |   data?: { [key: string]: any } | ||||||
|  | @ -57,7 +63,7 @@ type Referral = { | ||||||
| type Bonus = { | type Bonus = { | ||||||
|   fromType: 'BANK' |   fromType: 'BANK' | ||||||
|   toType: 'USER' |   toType: 'USER' | ||||||
|   category: 'UNIQUE_BETTOR_BONUS' |   category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export type DonationTxn = Txn & Donation | export type DonationTxn = Txn & Donation | ||||||
|  |  | ||||||
|  | @ -41,6 +41,8 @@ export type User = { | ||||||
|   referredByGroupId?: string |   referredByGroupId?: string | ||||||
|   lastPingTime?: number |   lastPingTime?: number | ||||||
|   shouldShowWelcome?: boolean |   shouldShowWelcome?: boolean | ||||||
|  |   lastBetTime?: number | ||||||
|  |   currentBettingStreak?: number | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 | export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 | ||||||
|  |  | ||||||
|  | @ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async ( | ||||||
|   } |   } | ||||||
|   return await notificationRef.set(removeUndefinedProps(notification)) |   return await notificationRef.set(removeUndefinedProps(notification)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export const createBettingStreakBonusNotification = async ( | ||||||
|  |   user: User, | ||||||
|  |   txnId: string, | ||||||
|  |   bet: Bet, | ||||||
|  |   contract: Contract, | ||||||
|  |   amount: number, | ||||||
|  |   idempotencyKey: string | ||||||
|  | ) => { | ||||||
|  |   const notificationRef = firestore | ||||||
|  |     .collection(`/users/${user.id}/notifications`) | ||||||
|  |     .doc(idempotencyKey) | ||||||
|  |   const notification: Notification = { | ||||||
|  |     id: idempotencyKey, | ||||||
|  |     userId: user.id, | ||||||
|  |     reason: 'betting_streak_incremented', | ||||||
|  |     createdTime: Date.now(), | ||||||
|  |     isSeen: false, | ||||||
|  |     sourceId: txnId, | ||||||
|  |     sourceType: 'betting_streak_bonus', | ||||||
|  |     sourceUpdateType: 'created', | ||||||
|  |     sourceUserName: user.name, | ||||||
|  |     sourceUserUsername: user.username, | ||||||
|  |     sourceUserAvatarUrl: user.avatarUrl, | ||||||
|  |     sourceText: amount.toString(), | ||||||
|  |     sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`, | ||||||
|  |     sourceTitle: 'Betting Streak Bonus', | ||||||
|  |     // Perhaps not necessary, but just in case
 | ||||||
|  |     sourceContractSlug: contract.slug, | ||||||
|  |     sourceContractId: contract.id, | ||||||
|  |     sourceContractTitle: contract.question, | ||||||
|  |     sourceContractCreatorUsername: contract.creatorUsername, | ||||||
|  |   } | ||||||
|  |   return await notificationRef.set(removeUndefinedProps(notification)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -25,6 +25,7 @@ export * from './on-create-comment-on-group' | ||||||
| export * from './on-create-txn' | export * from './on-create-txn' | ||||||
| export * from './on-delete-group' | export * from './on-delete-group' | ||||||
| export * from './score-contracts' | export * from './score-contracts' | ||||||
|  | export * from './reset-betting-streaks' | ||||||
| 
 | 
 | ||||||
| // v2
 | // v2
 | ||||||
| export * from './health' | export * from './health' | ||||||
|  |  | ||||||
|  | @ -3,15 +3,20 @@ import * as admin from 'firebase-admin' | ||||||
| import { keyBy, uniq } from 'lodash' | import { keyBy, uniq } from 'lodash' | ||||||
| 
 | 
 | ||||||
| import { Bet, LimitBet } from '../../common/bet' | import { Bet, LimitBet } from '../../common/bet' | ||||||
| import { getContract, getUser, getValues, isProd, log } from './utils' | import { getUser, getValues, isProd, log } from './utils' | ||||||
| import { | import { | ||||||
|   createBetFillNotification, |   createBetFillNotification, | ||||||
|  |   createBettingStreakBonusNotification, | ||||||
|   createNotification, |   createNotification, | ||||||
| } from './create-notification' | } from './create-notification' | ||||||
| import { filterDefined } from '../../common/util/array' | import { filterDefined } from '../../common/util/array' | ||||||
| import { Contract } from '../../common/contract' | import { Contract } from '../../common/contract' | ||||||
| import { runTxn, TxnData } from './transact' | import { runTxn, TxnData } from './transact' | ||||||
| import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' | import { | ||||||
|  |   BETTING_STREAK_BONUS_AMOUNT, | ||||||
|  |   BETTING_STREAK_RESET_HOUR, | ||||||
|  |   UNIQUE_BETTOR_BONUS_AMOUNT, | ||||||
|  | } from '../../common/numeric-constants' | ||||||
| import { | import { | ||||||
|   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, |   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, | ||||||
|   HOUSE_LIQUIDITY_PROVIDER_ID, |   HOUSE_LIQUIDITY_PROVIDER_ID, | ||||||
|  | @ -38,37 +43,99 @@ export const onCreateBet = functions.firestore | ||||||
|       .doc(contractId) |       .doc(contractId) | ||||||
|       .update({ lastBetTime, lastUpdatedTime: Date.now() }) |       .update({ lastBetTime, lastUpdatedTime: Date.now() }) | ||||||
| 
 | 
 | ||||||
|     await notifyFills(bet, contractId, eventId) |  | ||||||
|     await updateUniqueBettorsAndGiveCreatorBonus( |  | ||||||
|       contractId, |  | ||||||
|       eventId, |  | ||||||
|       bet.userId |  | ||||||
|     ) |  | ||||||
|   }) |  | ||||||
| 
 |  | ||||||
| const updateUniqueBettorsAndGiveCreatorBonus = async ( |  | ||||||
|   contractId: string, |  | ||||||
|   eventId: string, |  | ||||||
|   bettorId: string |  | ||||||
| ) => { |  | ||||||
|     const userContractSnap = await firestore |     const userContractSnap = await firestore | ||||||
|       .collection(`contracts`) |       .collection(`contracts`) | ||||||
|       .doc(contractId) |       .doc(contractId) | ||||||
|       .get() |       .get() | ||||||
|     const contract = userContractSnap.data() as Contract |     const contract = userContractSnap.data() as Contract | ||||||
|  | 
 | ||||||
|     if (!contract) { |     if (!contract) { | ||||||
|       log(`Could not find contract ${contractId}`) |       log(`Could not find contract ${contractId}`) | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
|  |     await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) | ||||||
|  | 
 | ||||||
|  |     const bettor = await getUser(bet.userId) | ||||||
|  |     if (!bettor) return | ||||||
|  | 
 | ||||||
|  |     await notifyFills(bet, contract, eventId, bettor) | ||||||
|  |     await updateBettingStreak(bettor, bet, contract, eventId) | ||||||
|  | 
 | ||||||
|  |     await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|  | const updateBettingStreak = async ( | ||||||
|  |   user: User, | ||||||
|  |   bet: Bet, | ||||||
|  |   contract: Contract, | ||||||
|  |   eventId: string | ||||||
|  | ) => { | ||||||
|  |   const betStreakResetTime = getTodaysBettingStreakResetTime() | ||||||
|  |   const lastBetTime = user?.lastBetTime ?? 0 | ||||||
|  | 
 | ||||||
|  |   // If they've already bet after the reset time, or if we haven't hit the reset time yet
 | ||||||
|  |   if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) | ||||||
|  |     return | ||||||
|  | 
 | ||||||
|  |   const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 | ||||||
|  |   // Otherwise, add 1 to their betting streak
 | ||||||
|  |   await firestore.collection('users').doc(user.id).update({ | ||||||
|  |     currentBettingStreak: newBettingStreak, | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|  |   // Send them the bonus times their streak
 | ||||||
|  |   const bonusAmount = Math.min( | ||||||
|  |     BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, | ||||||
|  |     100 | ||||||
|  |   ) | ||||||
|  |   const fromUserId = isProd() | ||||||
|  |     ? HOUSE_LIQUIDITY_PROVIDER_ID | ||||||
|  |     : DEV_HOUSE_LIQUIDITY_PROVIDER_ID | ||||||
|  |   const bonusTxnDetails = { | ||||||
|  |     currentBettingStreak: newBettingStreak, | ||||||
|  |   } | ||||||
|  |   const result = await firestore.runTransaction(async (trans) => { | ||||||
|  |     const bonusTxn: TxnData = { | ||||||
|  |       fromId: fromUserId, | ||||||
|  |       fromType: 'BANK', | ||||||
|  |       toId: user.id, | ||||||
|  |       toType: 'USER', | ||||||
|  |       amount: bonusAmount, | ||||||
|  |       token: 'M$', | ||||||
|  |       category: 'BETTING_STREAK_BONUS', | ||||||
|  |       description: JSON.stringify(bonusTxnDetails), | ||||||
|  |     } | ||||||
|  |     return await runTxn(trans, bonusTxn) | ||||||
|  |   }) | ||||||
|  |   if (!result.txn) { | ||||||
|  |     log("betting streak bonus txn couldn't be made") | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   await createBettingStreakBonusNotification( | ||||||
|  |     user, | ||||||
|  |     result.txn.id, | ||||||
|  |     bet, | ||||||
|  |     contract, | ||||||
|  |     bonusAmount, | ||||||
|  |     eventId | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const updateUniqueBettorsAndGiveCreatorBonus = async ( | ||||||
|  |   contract: Contract, | ||||||
|  |   eventId: string, | ||||||
|  |   bettorId: string | ||||||
|  | ) => { | ||||||
|   let previousUniqueBettorIds = contract.uniqueBettorIds |   let previousUniqueBettorIds = contract.uniqueBettorIds | ||||||
| 
 | 
 | ||||||
|   if (!previousUniqueBettorIds) { |   if (!previousUniqueBettorIds) { | ||||||
|     const contractBets = ( |     const contractBets = ( | ||||||
|       await firestore.collection(`contracts/${contractId}/bets`).get() |       await firestore.collection(`contracts/${contract.id}/bets`).get() | ||||||
|     ).docs.map((doc) => doc.data() as Bet) |     ).docs.map((doc) => doc.data() as Bet) | ||||||
| 
 | 
 | ||||||
|     if (contractBets.length === 0) { |     if (contractBets.length === 0) { | ||||||
|       log(`No bets for contract ${contractId}`) |       log(`No bets for contract ${contract.id}`) | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | ||||||
|   if (!contract.uniqueBettorIds || isNewUniqueBettor) { |   if (!contract.uniqueBettorIds || isNewUniqueBettor) { | ||||||
|     log(`Got ${previousUniqueBettorIds} unique bettors`) |     log(`Got ${previousUniqueBettorIds} unique bettors`) | ||||||
|     isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) |     isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) | ||||||
|     await firestore.collection(`contracts`).doc(contractId).update({ |     await firestore.collection(`contracts`).doc(contract.id).update({ | ||||||
|       uniqueBettorIds: newUniqueBettorIds, |       uniqueBettorIds: newUniqueBettorIds, | ||||||
|       uniqueBettorCount: newUniqueBettorIds.length, |       uniqueBettorCount: newUniqueBettorIds.length, | ||||||
|     }) |     }) | ||||||
|  | @ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | ||||||
| 
 | 
 | ||||||
|   // Create combined txn for all new unique bettors
 |   // Create combined txn for all new unique bettors
 | ||||||
|   const bonusTxnDetails = { |   const bonusTxnDetails = { | ||||||
|     contractId: contractId, |     contractId: contract.id, | ||||||
|     uniqueBettorIds: newUniqueBettorIds, |     uniqueBettorIds: newUniqueBettorIds, | ||||||
|   } |   } | ||||||
|   const fromUserId = isProd() |   const fromUserId = isProd() | ||||||
|  | @ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { | const notifyFills = async ( | ||||||
|  |   bet: Bet, | ||||||
|  |   contract: Contract, | ||||||
|  |   eventId: string, | ||||||
|  |   user: User | ||||||
|  | ) => { | ||||||
|   if (!bet.fills) return |   if (!bet.fills) return | ||||||
| 
 | 
 | ||||||
|   const user = await getUser(bet.userId) |  | ||||||
|   if (!user) return |  | ||||||
|   const contract = await getContract(contractId) |  | ||||||
|   if (!contract) return |  | ||||||
| 
 |  | ||||||
|   const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) |   const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) | ||||||
|   const matchedBets = ( |   const matchedBets = ( | ||||||
|     await Promise.all( |     await Promise.all( | ||||||
|  | @ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { | ||||||
|     }) |     }) | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | const getTodaysBettingStreakResetTime = () => { | ||||||
|  |   return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										38
									
								
								functions/src/reset-betting-streaks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								functions/src/reset-betting-streaks.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | ||||||
|  | // check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
 | ||||||
|  | 
 | ||||||
|  | import * as functions from 'firebase-functions' | ||||||
|  | import * as admin from 'firebase-admin' | ||||||
|  | import { User } from '../../common/user' | ||||||
|  | import { DAY_MS } from '../../common/util/time' | ||||||
|  | import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' | ||||||
|  | const firestore = admin.firestore() | ||||||
|  | 
 | ||||||
|  | export const resetBettingStreaksForUsers = functions.pubsub | ||||||
|  |   .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) | ||||||
|  |   .onRun(async () => { | ||||||
|  |     await resetBettingStreaksInternal() | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|  | const resetBettingStreaksInternal = async () => { | ||||||
|  |   const usersSnap = await firestore.collection('users').get() | ||||||
|  | 
 | ||||||
|  |   const users = usersSnap.docs.map((doc) => doc.data() as User) | ||||||
|  | 
 | ||||||
|  |   for (const user of users) { | ||||||
|  |     await resetBettingStreakForUser(user) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const resetBettingStreakForUser = async (user: User) => { | ||||||
|  |   const betStreakResetTime = Date.now() - DAY_MS | ||||||
|  |   // if they made a bet within the last day, don't reset their streak
 | ||||||
|  |   if ( | ||||||
|  |     (user.lastBetTime ?? 0 > betStreakResetTime) || | ||||||
|  |     !user.currentBettingStreak || | ||||||
|  |     user.currentBettingStreak === 0 | ||||||
|  |   ) | ||||||
|  |     return | ||||||
|  |   await firestore.collection('users').doc(user.id).update({ | ||||||
|  |     currentBettingStreak: 0, | ||||||
|  |   }) | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								web/components/profile/betting-streak-modal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/components/profile/betting-streak-modal.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | import { Modal } from 'web/components/layout/modal' | ||||||
|  | import { Col } from 'web/components/layout/col' | ||||||
|  | 
 | ||||||
|  | export function BettingStreakModal(props: { | ||||||
|  |   isOpen: boolean | ||||||
|  |   setOpen: (open: boolean) => void | ||||||
|  | }) { | ||||||
|  |   const { isOpen, setOpen } = props | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <Modal open={isOpen} setOpen={setOpen}> | ||||||
|  |       <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> | ||||||
|  |         <span className={'text-8xl'}>🔥</span> | ||||||
|  |         <span>Betting streaks are here!</span> | ||||||
|  |         <Col className={'gap-2'}> | ||||||
|  |           <span className={'text-indigo-700'}>• What are they?</span> | ||||||
|  |           <span className={'ml-2'}> | ||||||
|  |             You get a reward for every consecutive day that you place a bet. The | ||||||
|  |             more days you bet in a row, the more you earn! | ||||||
|  |           </span> | ||||||
|  |           <span className={'text-indigo-700'}> | ||||||
|  |             • Where can I check my streak? | ||||||
|  |           </span> | ||||||
|  |           <span className={'ml-2'}> | ||||||
|  |             You can see your current streak on the top right of your profile | ||||||
|  |             page. | ||||||
|  |           </span> | ||||||
|  |         </Col> | ||||||
|  |       </Col> | ||||||
|  |     </Modal> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | @ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button' | ||||||
| import { formatMoney } from 'common/util/format' | import { formatMoney } from 'common/util/format' | ||||||
| import { ShareIconButton } from 'web/components/share-icon-button' | import { ShareIconButton } from 'web/components/share-icon-button' | ||||||
| import { ENV_CONFIG } from 'common/envs/constants' | import { ENV_CONFIG } from 'common/envs/constants' | ||||||
|  | import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' | ||||||
| 
 | 
 | ||||||
| export function UserLink(props: { | export function UserLink(props: { | ||||||
|   name: string |   name: string | ||||||
|  | @ -65,10 +66,13 @@ export function UserPage(props: { user: User }) { | ||||||
|   const isCurrentUser = user.id === currentUser?.id |   const isCurrentUser = user.id === currentUser?.id | ||||||
|   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) | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const claimedMana = router.query['claimed-mana'] === 'yes' |     const claimedMana = router.query['claimed-mana'] === 'yes' | ||||||
|     setShowConfetti(claimedMana) |     setShowConfetti(claimedMana) | ||||||
|  |     const showBettingStreak = router.query['show'] === 'betting-streak' | ||||||
|  |     setShowBettingStreakModal(showBettingStreak) | ||||||
|   }, [router]) |   }, [router]) | ||||||
| 
 | 
 | ||||||
|   const profit = user.profitCached.allTime |   const profit = user.profitCached.allTime | ||||||
|  | @ -80,9 +84,14 @@ export function UserPage(props: { user: User }) { | ||||||
|         description={user.bio ?? ''} |         description={user.bio ?? ''} | ||||||
|         url={`/${user.username}`} |         url={`/${user.username}`} | ||||||
|       /> |       /> | ||||||
|       {showConfetti && ( |       {showConfetti || | ||||||
|  |         (showBettingStreakModal && ( | ||||||
|           <FullscreenConfetti recycle={false} numberOfPieces={300} /> |           <FullscreenConfetti recycle={false} numberOfPieces={300} /> | ||||||
|       )} |         ))} | ||||||
|  |       <BettingStreakModal | ||||||
|  |         isOpen={showBettingStreakModal} | ||||||
|  |         setOpen={setShowBettingStreakModal} | ||||||
|  |       /> | ||||||
|       {/* 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" | ||||||
|  | @ -114,9 +123,14 @@ export function UserPage(props: { user: User }) { | ||||||
| 
 | 
 | ||||||
|       {/* Profile details: name, username, bio, and link to twitter/discord */} |       {/* Profile details: name, username, bio, and link to twitter/discord */} | ||||||
|       <Col className="mx-4 -mt-6"> |       <Col className="mx-4 -mt-6"> | ||||||
|         <Row className={'items-center gap-2'}> |         <Row className={'justify-between'}> | ||||||
|  |           <Col> | ||||||
|             <span className="text-2xl font-bold">{user.name}</span> |             <span className="text-2xl font-bold">{user.name}</span> | ||||||
|           <span className="mt-1 text-gray-500"> |             <span className="text-gray-500">@{user.username}</span> | ||||||
|  |           </Col> | ||||||
|  |           <Col className={'justify-center gap-4'}> | ||||||
|  |             <Row> | ||||||
|  |               <Col className={'items-center text-gray-500'}> | ||||||
|                 <span |                 <span | ||||||
|                   className={clsx( |                   className={clsx( | ||||||
|                     'text-md', |                     'text-md', | ||||||
|  | @ -124,12 +138,19 @@ export function UserPage(props: { user: User }) { | ||||||
|                   )} |                   )} | ||||||
|                 > |                 > | ||||||
|                   {formatMoney(profit)} |                   {formatMoney(profit)} | ||||||
|             </span>{' '} |  | ||||||
|             profit |  | ||||||
|                 </span> |                 </span> | ||||||
|  |                 <span>profit</span> | ||||||
|  |               </Col> | ||||||
|  |               <Col | ||||||
|  |                 className={'cursor-pointer items-center text-gray-500'} | ||||||
|  |                 onClick={() => setShowBettingStreakModal(true)} | ||||||
|  |               > | ||||||
|  |                 <span>🔥{user.currentBettingStreak ?? 0}</span> | ||||||
|  |                 <span>streak</span> | ||||||
|  |               </Col> | ||||||
|  |             </Row> | ||||||
|  |           </Col> | ||||||
|         </Row> |         </Row> | ||||||
|         <span className="text-gray-500">@{user.username}</span> |  | ||||||
| 
 |  | ||||||
|         <Spacer h={4} /> |         <Spacer h={4} /> | ||||||
|         {user.bio && ( |         {user.bio && ( | ||||||
|           <> |           <> | ||||||
|  |  | ||||||
|  | @ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) { | ||||||
|     const notificationsGroupedByDay = notificationGroupsByDay[day] |     const notificationsGroupedByDay = notificationGroupsByDay[day] | ||||||
|     const incomeNotifications = notificationsGroupedByDay.filter( |     const incomeNotifications = notificationsGroupedByDay.filter( | ||||||
|       (notification) => |       (notification) => | ||||||
|         notification.sourceType === 'bonus' || notification.sourceType === 'tip' |         notification.sourceType === 'bonus' || | ||||||
|  |         notification.sourceType === 'tip' || | ||||||
|  |         notification.sourceType === 'betting_streak_bonus' | ||||||
|     ) |     ) | ||||||
|     const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( |     const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( | ||||||
|       (notification) => |       (notification) => | ||||||
|         notification.sourceType !== 'bonus' && notification.sourceType !== 'tip' |         notification.sourceType !== 'bonus' && | ||||||
|  |         notification.sourceType !== 'tip' && | ||||||
|  |         notification.sourceType !== 'betting_streak_bonus' | ||||||
|     ) |     ) | ||||||
|     if (incomeNotifications.length > 0) { |     if (incomeNotifications.length > 0) { | ||||||
|       notificationGroups = notificationGroups.concat({ |       notificationGroups = notificationGroups.concat({ | ||||||
|  |  | ||||||
|  | @ -31,7 +31,10 @@ import { | ||||||
| import { TrendingUpIcon } from '@heroicons/react/outline' | import { TrendingUpIcon } from '@heroicons/react/outline' | ||||||
| import { formatMoney } from 'common/util/format' | import { formatMoney } from 'common/util/format' | ||||||
| import { groupPath } from 'web/lib/firebase/groups' | import { groupPath } from 'web/lib/firebase/groups' | ||||||
| import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' | import { | ||||||
|  |   BETTING_STREAK_BONUS_AMOUNT, | ||||||
|  |   UNIQUE_BETTOR_BONUS_AMOUNT, | ||||||
|  | } from 'common/numeric-constants' | ||||||
| import { groupBy, sum, uniq } from 'lodash' | import { groupBy, sum, uniq } from 'lodash' | ||||||
| import { track } from '@amplitude/analytics-browser' | import { track } from '@amplitude/analytics-browser' | ||||||
| import { Pagination } from 'web/components/pagination' | import { Pagination } from 'web/components/pagination' | ||||||
|  | @ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: { | ||||||
|       (n) => n.sourceType |       (n) => n.sourceType | ||||||
|     ) |     ) | ||||||
|     for (const sourceType in groupedNotificationsBySourceType) { |     for (const sourceType in groupedNotificationsBySourceType) { | ||||||
|       // Source title splits by contracts and groups
 |       // Source title splits by contracts, groups, betting streak bonus
 | ||||||
|       const groupedNotificationsBySourceTitle = groupBy( |       const groupedNotificationsBySourceTitle = groupBy( | ||||||
|         groupedNotificationsBySourceType[sourceType], |         groupedNotificationsBySourceType[sourceType], | ||||||
|         (notification) => { |         (notification) => { | ||||||
|           return notification.sourceTitle ?? notification.sourceContractTitle |           return notification.sourceTitle ?? notification.sourceContractTitle | ||||||
|         } |         } | ||||||
|       ) |       ) | ||||||
|       for (const contractId in groupedNotificationsBySourceTitle) { |       for (const sourceTitle in groupedNotificationsBySourceTitle) { | ||||||
|         const notificationsForContractId = |         const notificationsForSourceTitle = | ||||||
|           groupedNotificationsBySourceTitle[contractId] |           groupedNotificationsBySourceTitle[sourceTitle] | ||||||
|         if (notificationsForContractId.length === 1) { |         if (notificationsForSourceTitle.length === 1) { | ||||||
|           newNotifications.push(notificationsForContractId[0]) |           newNotifications.push(notificationsForSourceTitle[0]) | ||||||
|           continue |           continue | ||||||
|         } |         } | ||||||
|         let sum = 0 |         let sum = 0 | ||||||
|         notificationsForContractId.forEach( |         notificationsForSourceTitle.forEach( | ||||||
|           (notification) => |           (notification) => | ||||||
|             notification.sourceText && |             notification.sourceText && | ||||||
|             (sum = parseInt(notification.sourceText) + sum) |             (sum = parseInt(notification.sourceText) + sum) | ||||||
|         ) |         ) | ||||||
|         const uniqueUsers = uniq( |         const uniqueUsers = uniq( | ||||||
|           notificationsForContractId.map((notification) => { |           notificationsForSourceTitle.map((notification) => { | ||||||
|             return notification.sourceUserUsername |             return notification.sourceUserUsername | ||||||
|           }) |           }) | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         const newNotification = { |         const newNotification = { | ||||||
|           ...notificationsForContractId[0], |           ...notificationsForSourceTitle[0], | ||||||
|           sourceText: sum.toString(), |           sourceText: sum.toString(), | ||||||
|           sourceUserUsername: |           sourceUserUsername: | ||||||
|             uniqueUsers.length > 1 |             uniqueUsers.length > 1 | ||||||
|               ? MULTIPLE_USERS_KEY |               ? MULTIPLE_USERS_KEY | ||||||
|               : notificationsForContractId[0].sourceType, |               : notificationsForSourceTitle[0].sourceType, | ||||||
|         } |         } | ||||||
|         newNotifications.push(newNotification) |         newNotifications.push(newNotification) | ||||||
|       } |       } | ||||||
|  | @ -362,7 +365,8 @@ function IncomeNotificationItem(props: { | ||||||
|   justSummary?: boolean |   justSummary?: boolean | ||||||
| }) { | }) { | ||||||
|   const { notification, justSummary } = props |   const { notification, justSummary } = props | ||||||
|   const { sourceType, sourceUserName, sourceUserUsername } = notification |   const { sourceType, sourceUserName, sourceUserUsername, sourceText } = | ||||||
|  |     notification | ||||||
|   const [highlighted] = useState(!notification.isSeen) |   const [highlighted] = useState(!notification.isSeen) | ||||||
|   const { width } = useWindowSize() |   const { width } = useWindowSize() | ||||||
|   const isMobile = (width && width < 768) || false |   const isMobile = (width && width < 768) || false | ||||||
|  | @ -370,19 +374,74 @@ function IncomeNotificationItem(props: { | ||||||
|     setNotificationsAsSeen([notification]) |     setNotificationsAsSeen([notification]) | ||||||
|   }, [notification]) |   }, [notification]) | ||||||
| 
 | 
 | ||||||
|   function getReasonForShowingIncomeNotification(simple: boolean) { |   function reasonAndLink(simple: boolean) { | ||||||
|     const { sourceText } = notification |     const { sourceText } = notification | ||||||
|     let reasonText = '' |     let reasonText = '' | ||||||
|     if (sourceType === 'bonus' && sourceText) { |     if (sourceType === 'bonus' && sourceText) { | ||||||
|       reasonText = !simple |       reasonText = !simple | ||||||
|         ? `Bonus for ${ |         ? `Bonus for ${ | ||||||
|             parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT |             parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT | ||||||
|           } unique traders` |           } unique traders on` | ||||||
|         : 'bonus on' |         : 'bonus on' | ||||||
|     } else if (sourceType === 'tip') { |     } else if (sourceType === 'tip') { | ||||||
|       reasonText = !simple ? `tipped you` : `in tips on` |       reasonText = !simple ? `tipped you on` : `in tips on` | ||||||
|  |     } else if (sourceType === 'betting_streak_bonus' && sourceText) { | ||||||
|  |       reasonText = `for your ${ | ||||||
|  |         parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT | ||||||
|  |       }-day` | ||||||
|     } |     } | ||||||
|     return reasonText |     return ( | ||||||
|  |       <> | ||||||
|  |         {reasonText} | ||||||
|  |         {sourceType === 'betting_streak_bonus' ? ( | ||||||
|  |           simple ? ( | ||||||
|  |             <span className={'ml-1 font-bold'}>Betting Streak</span> | ||||||
|  |           ) : ( | ||||||
|  |             <SiteLink | ||||||
|  |               className={'ml-1 font-bold'} | ||||||
|  |               href={'/betting-streak-bonus'} | ||||||
|  |             > | ||||||
|  |               Betting Streak | ||||||
|  |             </SiteLink> | ||||||
|  |           ) | ||||||
|  |         ) : ( | ||||||
|  |           <QuestionOrGroupLink | ||||||
|  |             notification={notification} | ||||||
|  |             ignoreClick={isMobile} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       </> | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const incomeNotificationLabel = () => { | ||||||
|  |     return sourceText ? ( | ||||||
|  |       <span className="text-primary"> | ||||||
|  |         {'+' + formatMoney(parseInt(sourceText))} | ||||||
|  |       </span> | ||||||
|  |     ) : ( | ||||||
|  |       <div /> | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const getIncomeSourceUrl = () => { | ||||||
|  |     const { | ||||||
|  |       sourceId, | ||||||
|  |       sourceContractCreatorUsername, | ||||||
|  |       sourceContractSlug, | ||||||
|  |       sourceSlug, | ||||||
|  |     } = notification | ||||||
|  |     if (sourceType === 'tip' && sourceContractSlug) | ||||||
|  |       return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` | ||||||
|  |     if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` | ||||||
|  |     if (sourceType === 'challenge') return `${sourceSlug}` | ||||||
|  |     if (sourceType === 'betting_streak_bonus') | ||||||
|  |       return `/${sourceUserUsername}/?show=betting-streak` | ||||||
|  |     if (sourceContractCreatorUsername && sourceContractSlug) | ||||||
|  |       return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( | ||||||
|  |         sourceId ?? '', | ||||||
|  |         sourceType | ||||||
|  |       )}` | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (justSummary) { |   if (justSummary) { | ||||||
|  | @ -392,19 +451,9 @@ function IncomeNotificationItem(props: { | ||||||
|           <div className={'flex pl-1 sm:pl-0'}> |           <div className={'flex pl-1 sm:pl-0'}> | ||||||
|             <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> |             <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> | ||||||
|               <div className={'mr-1 text-black'}> |               <div className={'mr-1 text-black'}> | ||||||
|                 <NotificationTextLabel |                 {incomeNotificationLabel()} | ||||||
|                   className={'line-clamp-1'} |  | ||||||
|                   notification={notification} |  | ||||||
|                   justSummary={true} |  | ||||||
|                 /> |  | ||||||
|               </div> |               </div> | ||||||
|               <span className={'flex truncate'}> |               <span className={'flex truncate'}>{reasonAndLink(true)}</span> | ||||||
|                 {getReasonForShowingIncomeNotification(true)} |  | ||||||
|                 <QuestionOrGroupLink |  | ||||||
|                   notification={notification} |  | ||||||
|                   ignoreClick={isMobile} |  | ||||||
|                 /> |  | ||||||
|               </span> |  | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|  | @ -421,18 +470,16 @@ function IncomeNotificationItem(props: { | ||||||
|     > |     > | ||||||
|       <div className={'relative'}> |       <div className={'relative'}> | ||||||
|         <SiteLink |         <SiteLink | ||||||
|           href={getSourceUrl(notification) ?? ''} |           href={getIncomeSourceUrl() ?? ''} | ||||||
|           className={'absolute left-0 right-0 top-0 bottom-0 z-0'} |           className={'absolute left-0 right-0 top-0 bottom-0 z-0'} | ||||||
|         /> |         /> | ||||||
|         <Row className={'items-center text-gray-500 sm:justify-start'}> |         <Row className={'items-center text-gray-500 sm:justify-start'}> | ||||||
|           <div className={'line-clamp-2 flex max-w-xl shrink '}> |           <div className={'line-clamp-2 flex max-w-xl shrink '}> | ||||||
|             <div className={'inline'}> |             <div className={'inline'}> | ||||||
|               <span className={'mr-1'}> |               <span className={'mr-1'}>{incomeNotificationLabel()}</span> | ||||||
|                 <NotificationTextLabel notification={notification} /> |  | ||||||
|               </span> |  | ||||||
|             </div> |             </div> | ||||||
|             <span> |             <span> | ||||||
|               {sourceType != 'bonus' && |               {sourceType === 'tip' && | ||||||
|                 (sourceUserUsername === MULTIPLE_USERS_KEY ? ( |                 (sourceUserUsername === MULTIPLE_USERS_KEY ? ( | ||||||
|                   <span className={'mr-1 truncate'}>Multiple users</span> |                   <span className={'mr-1 truncate'}>Multiple users</span> | ||||||
|                 ) : ( |                 ) : ( | ||||||
|  | @ -443,8 +490,7 @@ function IncomeNotificationItem(props: { | ||||||
|                     short={true} |                     short={true} | ||||||
|                   /> |                   /> | ||||||
|                 ))} |                 ))} | ||||||
|               {getReasonForShowingIncomeNotification(false)} {' on'} |               {reasonAndLink(false)} | ||||||
|               <QuestionOrGroupLink notification={notification} /> |  | ||||||
|             </span> |             </span> | ||||||
|           </div> |           </div> | ||||||
|         </Row> |         </Row> | ||||||
|  | @ -794,9 +840,6 @@ function getSourceUrl(notification: Notification) { | ||||||
|   // User referral:
 |   // User referral:
 | ||||||
|   if (sourceType === 'user' && !sourceContractSlug) |   if (sourceType === 'user' && !sourceContractSlug) | ||||||
|     return `/${sourceUserUsername}` |     return `/${sourceUserUsername}` | ||||||
|   if (sourceType === 'tip' && sourceContractSlug) |  | ||||||
|     return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` |  | ||||||
|   if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` |  | ||||||
|   if (sourceType === 'challenge') return `${sourceSlug}` |   if (sourceType === 'challenge') return `${sourceSlug}` | ||||||
|   if (sourceContractCreatorUsername && sourceContractSlug) |   if (sourceContractCreatorUsername && sourceContractSlug) | ||||||
|     return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( |     return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( | ||||||
|  | @ -885,12 +928,6 @@ function NotificationTextLabel(props: { | ||||||
|     return ( |     return ( | ||||||
|       <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> |       <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> | ||||||
|     ) |     ) | ||||||
|   } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { |  | ||||||
|     return ( |  | ||||||
|       <span className="text-primary"> |  | ||||||
|         {'+' + formatMoney(parseInt(sourceText))} |  | ||||||
|       </span> |  | ||||||
|     ) |  | ||||||
|   } else if (sourceType === 'bet' && sourceText) { |   } else if (sourceType === 'bet' && sourceText) { | ||||||
|     return ( |     return ( | ||||||
|       <> |       <> | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user