Loans2: Bets return you some capital every week (#588)

* Remove some old loan code

* Almost complete implementation of updateLoans cloud function

* Merge fixes

* Use invested instead of sale value, check if eligible, perform payouts

* Run monday 12am

* Implement loan income notification

* Fix imports

* Loan update fixes / debug

* Handle NaN and negative loan calcs

* Working loan notification

* Loan modal!

* Move loans calculation to /common

* Better layout

* Pay back loan on sell shares

* Pay back fraction of loan on redeem

* Sell bet loan: negate buy bet's loan

* Modal tweaks

* Compute and store nextLoanCached for all users

* lint

* Update loans with newest portfolio

* Filter loans to only unresolved contracts

* Tweak spacing

* Increase memory
This commit is contained in:
James Grugett 2022-08-22 00:22:49 -05:00 committed by GitHub
parent 3158740ea3
commit 8b7cd20b6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 436 additions and 87 deletions

View File

@ -61,5 +61,3 @@ export type fill = {
// I.e. -fill.shares === matchedBet.shares
isSale?: boolean
}
export const MAX_LOAN_PER_CONTRACT = 20

138
common/loans.ts Normal file
View File

@ -0,0 +1,138 @@
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import {
Contract,
CPMMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_WEEKLY_RATE = 0.05
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_WEEKLY_RATE
}
export const getLoanUpdates = (
users: User[],
contractsById: { [contractId: string]: Contract },
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
betsByUser: { [userId: string]: Bet[] }
) => {
const eligibleUsers = filterDefined(
users.map((user) =>
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
)
)
const betUpdates = eligibleUsers
.map((user) => {
const updates = calculateLoanBetUpdates(
betsByUser[user.id] ?? [],
contractsById
).betUpdates
return updates.map((update) => ({ ...update, user }))
})
.flat()
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
const userPayouts = Object.values(updatesByUser).map((updates) => {
return {
user: updates[0].user,
payout: sumBy(updates, (update) => update.newLoan),
}
})
return {
betUpdates,
userPayouts,
}
}
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
if (!portfolio) return true
const { balance, investmentValue } = portfolio
return balance + investmentValue > 0
}
const calculateLoanBetUpdates = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contracts = filterDefined(
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
).filter((c) => !c.isResolved)
const betUpdates = filterDefined(
contracts
.map((c) => {
if (c.mechanism === 'cpmm-1') {
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
} else if (
c.outcomeType === 'FREE_RESPONSE' ||
c.outcomeType === 'MULTIPLE_CHOICE'
)
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
else {
// Unsupported contract / mechanism for loans.
return []
}
})
.flat()
)
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
return {
totalNewLoan,
betUpdates,
}
}
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
const { invested } = getContractBetMetrics(contract, bets)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const oldestBet = minBy(bets, (bet) => bet.createdTime)
const newLoan = calculateNewLoan(invested, loanAmount)
if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
return {
userId: oldestBet.userId,
contractId: contract.id,
betId: oldestBet.id,
newLoan,
loanTotal,
}
}
const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0
const newLoan = calculateNewLoan(bet.amount, loanAmount)
const loanTotal = loanAmount + newLoan
if (isNaN(newLoan) || newLoan <= 0) return undefined
return {
userId: bet.userId,
contractId: contract.id,
betId: bet.id,
newLoan,
loanTotal,
}
})
}

View File

@ -1,6 +1,6 @@
import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import { Bet, fill, LimitBet, NumericBet } from './bet'
import {
calculateDpmShares,
getDpmProbability,
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,
contract: DPMBinaryContract,
loanAmount: number
contract: DPMBinaryContract
) => {
const { YES: yesPool, NO: noPool } = contract.pool
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
loanAmount: 0,
shares,
outcome,
probBefore,
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number
) => {
const { pool, totalShares, totalBets } = contract
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
loanAmount: 0,
shares,
outcome,
probBefore,
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
return { newBet, newPool, newTotalShares, newTotalBets }
}
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -39,6 +39,7 @@ export type notification_source_types =
| 'bonus'
| 'challenge'
| 'betting_streak_bonus'
| 'loan'
export type notification_source_update_types =
| 'created'
@ -68,3 +69,4 @@ export type notification_reason_types =
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'

View File

@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares)
const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount }
}

View File

@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet
const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateDpmShareValue(contract, bet)
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
betId,
},
fees,
loanAmount: -(loanAmount ?? 0),
}
return {
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
shares: number,
outcome: 'YES' | 'NO',
contract: CPMMContract,
prevLoanAmount: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
loanPaid: number
) => {
const { pool, p } = contract
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
unfilledBets
)
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -32,6 +32,7 @@ export type User = {
allTime: number
}
nextLoanCached: number
followerCountCached: number
followedCategories?: string[]

View File

@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
}
transaction.create(newAnswerDoc, answer)
const loanAmount = 0
const { newBet, newPool, newTotalShares, newTotalBets } =
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
getNewMultiBetInfo(answerId, amount, contract)
const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()

View File

@ -471,6 +471,32 @@ export const createReferralNotification = async (
await notificationRef.set(removeUndefinedProps(notification))
}
export const createLoanIncomeNotification = async (
toUser: User,
idempotencyKey: string,
income: number
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'loan_income',
createdTime: Date.now(),
isSeen: false,
sourceId: idempotencyKey,
sourceType: 'loan',
sourceUpdateType: 'updated',
sourceUserName: toUser.name,
sourceUserUsername: toUser.username,
sourceUserAvatarUrl: toUser.avatarUrl,
sourceText: income.toString(),
sourceTitle: 'Loan',
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (

View File

@ -75,6 +75,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
nextLoanCached: 0,
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,

View File

@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'
export * from './market-close-notifications'
export * from './on-create-answer'

View File

@ -59,7 +59,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
@ -119,7 +118,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
return getNewMultiBetInfo(outcome, amount, contract)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract)

View File

@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const saleAmount = newBet.sale!.amount
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0)
const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.update(userDoc, { balance: newBalance })
transaction.update(betDoc, { isSold: true })
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update(
contractDoc,

View File

@ -7,7 +7,7 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils'
import { log } from './utils'
import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
@ -28,12 +28,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBets] = await Promise.all([
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
@ -45,7 +49,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares)
@ -77,18 +81,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const soldShares = Math.min(sharesToSell, maxShares)
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const saleFrac = soldShares / maxShares
let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
prevLoanAmount,
unfilledBets
unfilledBets,
loanPaid
)
if (
@ -104,7 +106,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount),
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
})
transaction.create(newBetDoc, {
id: newBetDoc.id,

View File

@ -0,0 +1,88 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, keyBy } from 'lodash'
import { getValues, log, payUser, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every Monday.
.pubsub.schedule('0 0 * * 1')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
async function updateLoansCore() {
log('Updating loans...')
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
),
getValues<Bet>(firestore.collectionGroup('bets')),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
const contractsById = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const { betUpdates, userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
log(`${betUpdates.length} bet updates.`)
const betDocUpdates = betUpdates.map((update) => ({
doc: firestore
.collection('contracts')
.doc(update.contractId)
.collection('bets')
.doc(update.betId),
fields: {
loanAmount: update.loanTotal,
},
}))
await writeAsync(firestore, betDocUpdates)
log(`${userPayouts.length} user payouts`)
await Promise.all(
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
)
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
userPayouts.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
)
log('Notifications sent!')
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans'
const firestore = admin.firestore()
@ -21,7 +22,9 @@ const computeInvestmentValue = (
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0)
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userUpdates = users.map((user) => {
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
newPortfolio,
didProfitChange
)
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
}
})
const portfolioByUser = Object.fromEntries(
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
)
const { userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
...(didProfitChange && {
profitCached: newProfit,
}),
nextLoanCached,
},
},
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
},
},
}
})
}
)
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
@ -234,6 +262,6 @@ const calculateNewProfit = (
}
export const updateMetrics = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
<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>Daily betting streaks</span>
<span className="text-xl">Daily betting streaks</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>

View File

@ -0,0 +1,47 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function LoansModal(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 className="text-xl">Loans on your bets</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are loans?</span>
<span className={'ml-2'}>
Every Monday, get 5% of your total bet amount back as a loan.
</span>
<span className={'text-indigo-700'}>
Do I have to pay back a loan?
</span>
<span className={'ml-2'}>
Yes, don't worry! You will automatically pay back loans when the
market resolves or you sell your bet.
</span>
<span className={'text-indigo-700'}>
What is the purpose of loans?
</span>
<span className={'ml-2'}>
Loans make it worthwhile to bet on markets that won't resolve for
months or years, because your investment won't be locked up as long.
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$100 on "Will I become a millionare?" on
Sunday, you will get M$5 back on Monday.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So, the next
week, you would get back 5% of M$95 = M$4.75.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -29,6 +29,7 @@ import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
import { LoansModal } from './profile/loans-modal'
import { REFERRAL_AMOUNT } from 'common/user'
export function UserLink(props: {
@ -68,6 +69,7 @@ export function UserPage(props: { user: User }) {
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
@ -75,6 +77,9 @@ export function UserPage(props: { user: User }) {
setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
const query = { ...router.query }
if (query.claimedMana || query.show) {
delete query['claimed-mana']
@ -107,6 +112,9 @@ export function UserPage(props: { user: User }) {
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
/>
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
)}
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
@ -128,7 +136,7 @@ export function UserPage(props: { user: User }) {
<div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && (
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
<SiteLink className="btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</SiteLink>
@ -138,9 +146,14 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<Row className={'justify-between'}>
<Row className={'flex-wrap justify-between gap-y-2'}>
<Col>
<span className="text-2xl font-bold">{user.name}</span>
<span
className="text-2xl font-bold"
style={{ wordBreak: 'break-word' }}
>
{user.name}
</span>
<span className="text-gray-500">@{user.username}</span>
</Col>
<Col className={'justify-center'}>
@ -163,6 +176,17 @@ export function UserPage(props: { user: User }) {
<span>🔥 {user.currentBettingStreak ?? 0}</span>
<span>streak</span>
</Col>
<Col
className={
'flex-shrink-0 cursor-pointer items-center text-gray-500'
}
onClick={() => setShowLoansModal(true)}
>
<span className="text-green-600">
🏦 {formatMoney(user.nextLoanCached ?? 0)}
</span>
<span>next loan</span>
</Col>
</Row>
</Col>
</Row>

View File

@ -5,7 +5,7 @@ import {
getNotificationsQuery,
listenForNotifications,
} from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash'
import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day]
const incomeNotifications = notificationsGroupedByDay.filter(
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
notificationsGroupedByDay,
(notification) =>
notification.sourceType === 'bonus' ||
notification.sourceType === 'tip' ||
notification.sourceType === 'betting_streak_bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType !== 'bonus' &&
notification.sourceType !== 'tip' &&
notification.sourceType !== 'betting_streak_bonus'
incomeSourceTypes.includes(notification.sourceType ?? '')
)
if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({

View File

@ -388,6 +388,8 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as`
}
const bettingStreakText =
@ -401,7 +403,15 @@ function IncomeNotificationItem(props: {
return (
<>
{reasonText}
{sourceType === 'betting_streak_bonus' ? (
{sourceType === 'loan' ? (
simple ? (
<span className={'ml-1 font-bold'}>Loan</span>
) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
Loan
</SiteLink>
)
) : sourceType === 'betting_streak_bonus' ? (
simple ? (
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : (
@ -445,6 +455,7 @@ function IncomeNotificationItem(props: {
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',